flow-twinx 0.1.0__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.
- flow_twinx-0.1.0/PKG-INFO +103 -0
- flow_twinx-0.1.0/README.md +94 -0
- flow_twinx-0.1.0/flow_twinx/Offline/__init__.py +5 -0
- flow_twinx-0.1.0/flow_twinx/Offline/commands.py +207 -0
- flow_twinx-0.1.0/flow_twinx/Offline/file.py +77 -0
- flow_twinx-0.1.0/flow_twinx/Offline/player.py +64 -0
- flow_twinx-0.1.0/flow_twinx/Offline/youtube.py +0 -0
- flow_twinx-0.1.0/flow_twinx/Online/__init__.py +5 -0
- flow_twinx-0.1.0/flow_twinx/Online/commands.py +205 -0
- flow_twinx-0.1.0/flow_twinx/Online/player.py +69 -0
- flow_twinx-0.1.0/flow_twinx/Online/youtube.py +31 -0
- flow_twinx-0.1.0/flow_twinx/__init__.py +0 -0
- flow_twinx-0.1.0/flow_twinx/config.py +36 -0
- flow_twinx-0.1.0/flow_twinx/main.py +79 -0
- flow_twinx-0.1.0/flow_twinx/ping.py +38 -0
- flow_twinx-0.1.0/flow_twinx/tui.py +69 -0
- flow_twinx-0.1.0/flow_twinx.egg-info/PKG-INFO +103 -0
- flow_twinx-0.1.0/flow_twinx.egg-info/SOURCES.txt +23 -0
- flow_twinx-0.1.0/flow_twinx.egg-info/dependency_links.txt +1 -0
- flow_twinx-0.1.0/flow_twinx.egg-info/entry_points.txt +2 -0
- flow_twinx-0.1.0/flow_twinx.egg-info/requires.txt +2 -0
- flow_twinx-0.1.0/flow_twinx.egg-info/top_level.txt +1 -0
- flow_twinx-0.1.0/pyproject.toml +21 -0
- flow_twinx-0.1.0/requirements.txt +2 -0
- flow_twinx-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flow-twinx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal music player with online and offline modes
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: python-vlc
|
|
8
|
+
Requires-Dist: yt_dlp
|
|
9
|
+
|
|
10
|
+
# Flow
|
|
11
|
+
|
|
12
|
+
A terminal-based music player with online streaming and offline library modes.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Dual-mode operation** — Automatically detects internet and switches between online streaming and offline playback
|
|
17
|
+
- **Online mode** — Search and stream audio from YouTube via `yt-dlp` and `python-vlc`
|
|
18
|
+
- **Offline mode** — Play local audio files with album support, search, and a liked-songs collection
|
|
19
|
+
- **Download** — Save tracks from YouTube to your local library with the `-d` flag
|
|
20
|
+
- **Repeat & shuffle** — Loop tracks or play in random order
|
|
21
|
+
- **Like/unlike** — Save favorites to a dedicated playlist
|
|
22
|
+
- **Tab completion** — Auto-complete commands and song names in offline mode
|
|
23
|
+
- **Colored TUI** — Cyan theme for online, magenta for offline, with borders and banners
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- Python 3
|
|
28
|
+
- [VLC](https://www.videolan.org/vlc/) media player (for `python-vlc` bindings)
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/yourusername/flow.git
|
|
34
|
+
cd flow
|
|
35
|
+
python -m venv .venv
|
|
36
|
+
.venv\Scripts\pip install -r requirements.txt
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or use the installer script:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bash install.sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python main.py [-r] [-s] [-d]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or direct if you have used install.sh
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
flow <command> <flag>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Flag | Description |
|
|
58
|
+
| ---- | ---------------------------------------------- |
|
|
59
|
+
| `-r` | Repeat mode (loop current track) |
|
|
60
|
+
| `-s` | Shuffle mode (random order) |
|
|
61
|
+
| `-d` | Download mode (save streamed audio to library) |
|
|
62
|
+
|
|
63
|
+
### Commands
|
|
64
|
+
|
|
65
|
+
| Command | Description |
|
|
66
|
+
| ---------------------- | -------------------------------------------- |
|
|
67
|
+
| `play <name or #>` | Play a song by name or search result number |
|
|
68
|
+
| `search <query>` | Search YouTube (online) or library (offline) |
|
|
69
|
+
| `list` | Show all songs, albums, or liked tracks |
|
|
70
|
+
| `like <name or #>` | Add a song to your liked collection |
|
|
71
|
+
| `download <name or #>` | Save a streamed song to the local library |
|
|
72
|
+
| `switch` | Toggle between online and offline mode |
|
|
73
|
+
| `help` | Show available commands |
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
- Downloads and library are stored in `~/.flow/downloads/`
|
|
78
|
+
- Liked songs (online) are saved to `~/.flow/liked.txt`
|
|
79
|
+
- Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
|
|
80
|
+
|
|
81
|
+
## Project Structure
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Flow/
|
|
85
|
+
├── config.py # Configuration and theme settings
|
|
86
|
+
├── main.py # Entry point and REPL loop
|
|
87
|
+
├── ping.py # Internet connectivity check
|
|
88
|
+
├── tui.py # Terminal UI helpers
|
|
89
|
+
├── requirements.txt # Python dependencies
|
|
90
|
+
├── install.sh # Installer script
|
|
91
|
+
├── Online/
|
|
92
|
+
│ ├── commands.py # Online mode commands
|
|
93
|
+
│ ├── player.py # VLC streaming player
|
|
94
|
+
│ └── youtube.py # YouTube search and download
|
|
95
|
+
└── Offline/
|
|
96
|
+
├── commands.py # Offline mode commands
|
|
97
|
+
├── file.py # Library scanner and search
|
|
98
|
+
└── player.py # Local file player
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Flow
|
|
2
|
+
|
|
3
|
+
A terminal-based music player with online streaming and offline library modes.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Dual-mode operation** — Automatically detects internet and switches between online streaming and offline playback
|
|
8
|
+
- **Online mode** — Search and stream audio from YouTube via `yt-dlp` and `python-vlc`
|
|
9
|
+
- **Offline mode** — Play local audio files with album support, search, and a liked-songs collection
|
|
10
|
+
- **Download** — Save tracks from YouTube to your local library with the `-d` flag
|
|
11
|
+
- **Repeat & shuffle** — Loop tracks or play in random order
|
|
12
|
+
- **Like/unlike** — Save favorites to a dedicated playlist
|
|
13
|
+
- **Tab completion** — Auto-complete commands and song names in offline mode
|
|
14
|
+
- **Colored TUI** — Cyan theme for online, magenta for offline, with borders and banners
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Python 3
|
|
19
|
+
- [VLC](https://www.videolan.org/vlc/) media player (for `python-vlc` bindings)
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/yourusername/flow.git
|
|
25
|
+
cd flow
|
|
26
|
+
python -m venv .venv
|
|
27
|
+
.venv\Scripts\pip install -r requirements.txt
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or use the installer script:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bash install.sh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python main.py [-r] [-s] [-d]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or direct if you have used install.sh
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
flow <command> <flag>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Flag | Description |
|
|
49
|
+
| ---- | ---------------------------------------------- |
|
|
50
|
+
| `-r` | Repeat mode (loop current track) |
|
|
51
|
+
| `-s` | Shuffle mode (random order) |
|
|
52
|
+
| `-d` | Download mode (save streamed audio to library) |
|
|
53
|
+
|
|
54
|
+
### Commands
|
|
55
|
+
|
|
56
|
+
| Command | Description |
|
|
57
|
+
| ---------------------- | -------------------------------------------- |
|
|
58
|
+
| `play <name or #>` | Play a song by name or search result number |
|
|
59
|
+
| `search <query>` | Search YouTube (online) or library (offline) |
|
|
60
|
+
| `list` | Show all songs, albums, or liked tracks |
|
|
61
|
+
| `like <name or #>` | Add a song to your liked collection |
|
|
62
|
+
| `download <name or #>` | Save a streamed song to the local library |
|
|
63
|
+
| `switch` | Toggle between online and offline mode |
|
|
64
|
+
| `help` | Show available commands |
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
- Downloads and library are stored in `~/.flow/downloads/`
|
|
69
|
+
- Liked songs (online) are saved to `~/.flow/liked.txt`
|
|
70
|
+
- Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
|
|
71
|
+
|
|
72
|
+
## Project Structure
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Flow/
|
|
76
|
+
├── config.py # Configuration and theme settings
|
|
77
|
+
├── main.py # Entry point and REPL loop
|
|
78
|
+
├── ping.py # Internet connectivity check
|
|
79
|
+
├── tui.py # Terminal UI helpers
|
|
80
|
+
├── requirements.txt # Python dependencies
|
|
81
|
+
├── install.sh # Installer script
|
|
82
|
+
├── Online/
|
|
83
|
+
│ ├── commands.py # Online mode commands
|
|
84
|
+
│ ├── player.py # VLC streaming player
|
|
85
|
+
│ └── youtube.py # YouTube search and download
|
|
86
|
+
└── Offline/
|
|
87
|
+
├── commands.py # Offline mode commands
|
|
88
|
+
├── file.py # Library scanner and search
|
|
89
|
+
└── player.py # Local file player
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from .. import config
|
|
3
|
+
from ..config import merge_flags
|
|
4
|
+
from . import file as lib
|
|
5
|
+
from . import player
|
|
6
|
+
from ..tui import print as tprint
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import readline
|
|
10
|
+
_HAS_READLINE = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
_HAS_READLINE = False
|
|
13
|
+
|
|
14
|
+
_last_results = []
|
|
15
|
+
_last_played = None
|
|
16
|
+
|
|
17
|
+
COMMANDS = {
|
|
18
|
+
"play": "Play song(s) from local library",
|
|
19
|
+
"search": "Search local music library",
|
|
20
|
+
"list": "List local music library",
|
|
21
|
+
"like": "Like the currently playing song",
|
|
22
|
+
"switch": "Switch to Online mode (checks connection)",
|
|
23
|
+
"help": "Show this help message",
|
|
24
|
+
"exit": "Exit Flow",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
m = tprint(color="grey", border="none")
|
|
28
|
+
e = tprint(color="red", border="none")
|
|
29
|
+
i = tprint(color="theme", border="none")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Completer:
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.matches = []
|
|
35
|
+
|
|
36
|
+
def complete(self, text, state):
|
|
37
|
+
if state == 0:
|
|
38
|
+
line = readline.get_line_buffer()
|
|
39
|
+
parts = line.split()
|
|
40
|
+
if len(parts) <= 1:
|
|
41
|
+
options = [c for c in COMMANDS if c.startswith(text)]
|
|
42
|
+
elif parts[0] == "play":
|
|
43
|
+
songs = lib.get_song_names()
|
|
44
|
+
albums = lib.get_album_names()
|
|
45
|
+
all_names = songs + albums
|
|
46
|
+
options = sorted(set(
|
|
47
|
+
n for n in all_names
|
|
48
|
+
if n.lower().startswith(text.lower())
|
|
49
|
+
))
|
|
50
|
+
else:
|
|
51
|
+
options = []
|
|
52
|
+
self.matches = options
|
|
53
|
+
try:
|
|
54
|
+
return self.matches[state]
|
|
55
|
+
except IndexError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _setup_completion():
|
|
60
|
+
if not _HAS_READLINE:
|
|
61
|
+
return
|
|
62
|
+
readline.set_completer(_Completer().complete)
|
|
63
|
+
readline.parse_and_bind("tab: complete")
|
|
64
|
+
readline.set_completer_delims(' \t\n;')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run(cmd: str, extra: list[str], args):
|
|
68
|
+
_setup_completion()
|
|
69
|
+
extra, args = merge_flags(extra, args)
|
|
70
|
+
if cmd == "play":
|
|
71
|
+
play(extra, args)
|
|
72
|
+
elif cmd == "search":
|
|
73
|
+
search(" ".join(extra) if extra else "")
|
|
74
|
+
elif cmd == "list":
|
|
75
|
+
list_library()
|
|
76
|
+
elif cmd == "like":
|
|
77
|
+
like_track()
|
|
78
|
+
elif cmd == "switch":
|
|
79
|
+
switch_mode()
|
|
80
|
+
elif cmd == "help":
|
|
81
|
+
show_help()
|
|
82
|
+
else:
|
|
83
|
+
e(f"Unknown command: {cmd}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def play(extra: list[str], args):
|
|
87
|
+
global _last_results, _last_played
|
|
88
|
+
arg = " ".join(extra) if extra else None
|
|
89
|
+
if not arg:
|
|
90
|
+
e("No song specified")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if arg == "liked":
|
|
94
|
+
_play_liked(args)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if arg in lib.get_album_names():
|
|
98
|
+
_play_album(arg, args)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if arg.isdigit():
|
|
102
|
+
idx = int(arg) - 1
|
|
103
|
+
if idx < 0 or idx >= len(_last_results):
|
|
104
|
+
e("Index out of range")
|
|
105
|
+
return
|
|
106
|
+
song_path = _last_results[idx]
|
|
107
|
+
else:
|
|
108
|
+
results = lib.find_songs(arg)
|
|
109
|
+
if not results:
|
|
110
|
+
e(f"No songs found matching '{arg}'")
|
|
111
|
+
return
|
|
112
|
+
if len(results) == 1:
|
|
113
|
+
song_path = results[0]
|
|
114
|
+
else:
|
|
115
|
+
_last_results = results
|
|
116
|
+
m("Multiple matches:")
|
|
117
|
+
for i, p in enumerate(results, 1):
|
|
118
|
+
m(f" {i}. {p.stem}")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
_last_played = song_path
|
|
122
|
+
player.play_file(song_path, song_path.stem, args)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _play_liked(args):
|
|
126
|
+
liked = lib.get_liked_songs()
|
|
127
|
+
if not liked:
|
|
128
|
+
e("No liked songs yet")
|
|
129
|
+
return
|
|
130
|
+
if getattr(args, "s", False):
|
|
131
|
+
random.shuffle(liked)
|
|
132
|
+
for song in liked:
|
|
133
|
+
_last_played = song
|
|
134
|
+
player.play_file(song, song.stem, args)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _play_album(album: str, args):
|
|
138
|
+
songs = lib.get_album_songs(album)
|
|
139
|
+
if not songs:
|
|
140
|
+
e(f"No songs found in album '{album}'")
|
|
141
|
+
return
|
|
142
|
+
if getattr(args, "s", False):
|
|
143
|
+
random.shuffle(songs)
|
|
144
|
+
for song in songs:
|
|
145
|
+
_last_played = song
|
|
146
|
+
player.play_file(song, song.stem, args)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def like_track():
|
|
150
|
+
global _last_played
|
|
151
|
+
if not _last_played:
|
|
152
|
+
e("No song currently playing")
|
|
153
|
+
return
|
|
154
|
+
dest = lib.like_song(_last_played)
|
|
155
|
+
if dest:
|
|
156
|
+
i(f"Liked: {_last_played.stem}")
|
|
157
|
+
else:
|
|
158
|
+
m(f"{_last_played.stem} is already liked")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def search(query: str):
|
|
162
|
+
global _last_results
|
|
163
|
+
if not query:
|
|
164
|
+
e("Search query required")
|
|
165
|
+
return
|
|
166
|
+
results = lib.find_songs(query)
|
|
167
|
+
if not results:
|
|
168
|
+
e("No results found")
|
|
169
|
+
return
|
|
170
|
+
_last_results = results
|
|
171
|
+
for i, p in enumerate(results, 1):
|
|
172
|
+
m(f" {i}. {p.stem}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def list_library():
|
|
176
|
+
songs = lib.get_songs()
|
|
177
|
+
albums = lib.get_albums()
|
|
178
|
+
liked = lib.get_liked_songs()
|
|
179
|
+
|
|
180
|
+
if songs:
|
|
181
|
+
i("\nSongs:")
|
|
182
|
+
for p in songs:
|
|
183
|
+
m(f" {p.stem}")
|
|
184
|
+
if albums:
|
|
185
|
+
i("\nAlbums:")
|
|
186
|
+
for a in albums:
|
|
187
|
+
m(f" {a}/")
|
|
188
|
+
if liked:
|
|
189
|
+
i("\nLiked Songs:")
|
|
190
|
+
for p in liked:
|
|
191
|
+
m(f" {p.stem}")
|
|
192
|
+
if not songs and not albums and not liked:
|
|
193
|
+
e("No music in library")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def switch_mode():
|
|
197
|
+
from ..ping import is_connected
|
|
198
|
+
if is_connected():
|
|
199
|
+
i("Switched to Online mode")
|
|
200
|
+
else:
|
|
201
|
+
e("No internet connection")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def show_help():
|
|
205
|
+
i("Offline Commands:")
|
|
206
|
+
for cmd, desc in COMMANDS.items():
|
|
207
|
+
m(f" {cmd:12s} {desc}")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import shutil
|
|
3
|
+
from .. import config
|
|
4
|
+
|
|
5
|
+
AUDIO_EXTENSIONS = {".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus", ".wma", ".aac",".webm"}
|
|
6
|
+
LIKED_DIR_NAME = "liked songs"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _liked_dir() -> pathlib.Path:
|
|
10
|
+
return config.DOWNLOAD_DIR / LIKED_DIR_NAME
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_all_songs() -> list[pathlib.Path]:
|
|
14
|
+
if not config.DOWNLOAD_DIR.exists():
|
|
15
|
+
return []
|
|
16
|
+
return sorted([
|
|
17
|
+
p for p in config.DOWNLOAD_DIR.rglob("*")
|
|
18
|
+
if p.suffix.lower() in AUDIO_EXTENSIONS
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_songs() -> list[pathlib.Path]:
|
|
23
|
+
liked = _liked_dir()
|
|
24
|
+
return [s for s in get_all_songs() if liked not in s.parents]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_albums() -> list[str]:
|
|
28
|
+
if not config.DOWNLOAD_DIR.exists():
|
|
29
|
+
return []
|
|
30
|
+
liked = _liked_dir()
|
|
31
|
+
return sorted([
|
|
32
|
+
d.name for d in config.DOWNLOAD_DIR.iterdir()
|
|
33
|
+
if d.is_dir() and d != liked
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_album_songs(album: str) -> list[pathlib.Path]:
|
|
38
|
+
album_dir = config.DOWNLOAD_DIR / album
|
|
39
|
+
if not album_dir.exists() or not album_dir.is_dir():
|
|
40
|
+
return []
|
|
41
|
+
return sorted([
|
|
42
|
+
p for p in album_dir.iterdir()
|
|
43
|
+
if p.suffix.lower() in AUDIO_EXTENSIONS
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_liked_songs() -> list[pathlib.Path]:
|
|
48
|
+
liked = _liked_dir()
|
|
49
|
+
if not liked.exists():
|
|
50
|
+
return []
|
|
51
|
+
return sorted([
|
|
52
|
+
p for p in liked.iterdir()
|
|
53
|
+
if p.suffix.lower() in AUDIO_EXTENSIONS
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def like_song(song_path: pathlib.Path) -> pathlib.Path | None:
|
|
58
|
+
liked = _liked_dir()
|
|
59
|
+
liked.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
dest = liked / song_path.name
|
|
61
|
+
if dest.exists():
|
|
62
|
+
return None
|
|
63
|
+
shutil.copy2(song_path, dest)
|
|
64
|
+
return dest
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_songs(query: str) -> list[pathlib.Path]:
|
|
68
|
+
q = query.lower()
|
|
69
|
+
return [s for s in get_songs() if q in s.stem.lower()]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_song_names() -> list[str]:
|
|
73
|
+
return sorted(set(s.stem for s in get_songs()))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_album_names() -> list[str]:
|
|
77
|
+
return get_albums()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import vlc
|
|
4
|
+
from ..tui import print as tprint
|
|
5
|
+
|
|
6
|
+
m = tprint(color="grey", border="none")
|
|
7
|
+
e = tprint(color="red", border="none")
|
|
8
|
+
i = tprint(color="theme", border="none")
|
|
9
|
+
|
|
10
|
+
BAR_WIDTH = 40
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _progress_bar(elapsed, total):
|
|
14
|
+
if total <= 0:
|
|
15
|
+
return ""
|
|
16
|
+
fraction = min(elapsed / total, 1.0)
|
|
17
|
+
filled = int(fraction * BAR_WIDTH)
|
|
18
|
+
bar = "/" * filled + " " * (BAR_WIDTH - filled)
|
|
19
|
+
pct = int(fraction * 100)
|
|
20
|
+
return f" [{bar}] {pct}%"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def play_file(filepath, title, args=None):
|
|
24
|
+
repeat = args and getattr(args, "r", False)
|
|
25
|
+
shuffle = args and getattr(args, "s", False)
|
|
26
|
+
|
|
27
|
+
while True:
|
|
28
|
+
instance = vlc.Instance("--no-video --quiet")
|
|
29
|
+
player = instance.media_player_new()
|
|
30
|
+
media = instance.media_new(str(filepath))
|
|
31
|
+
player.set_media(media)
|
|
32
|
+
player.play()
|
|
33
|
+
|
|
34
|
+
duration = 0
|
|
35
|
+
for _ in range(50):
|
|
36
|
+
duration = player.get_length() / 1000
|
|
37
|
+
if duration > 0:
|
|
38
|
+
break
|
|
39
|
+
time.sleep(0.1)
|
|
40
|
+
if duration <= 0:
|
|
41
|
+
duration = 0
|
|
42
|
+
dur_min, dur_sec = divmod(int(duration), 60)
|
|
43
|
+
i(f"\nPlaying : {title}")
|
|
44
|
+
m(f" {dur_min}:{dur_sec:02d} | repeat:{repeat} | shuffle:{shuffle}")
|
|
45
|
+
|
|
46
|
+
start = time.time()
|
|
47
|
+
try:
|
|
48
|
+
while player.get_state() not in (vlc.State.Ended, vlc.State.Error):
|
|
49
|
+
elapsed = time.time() - start
|
|
50
|
+
bar = _progress_bar(elapsed, duration)
|
|
51
|
+
sys.stdout.write(f"\r\x1b[35m{bar}\x1b[0m")
|
|
52
|
+
sys.stdout.flush()
|
|
53
|
+
time.sleep(0.5)
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
player.stop()
|
|
56
|
+
sys.stdout.write("\n")
|
|
57
|
+
sys.stdout.flush()
|
|
58
|
+
break
|
|
59
|
+
|
|
60
|
+
sys.stdout.write("\n")
|
|
61
|
+
sys.stdout.flush()
|
|
62
|
+
|
|
63
|
+
if not repeat:
|
|
64
|
+
break
|
|
File without changes
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from .. import config
|
|
6
|
+
from ..config import merge_flags
|
|
7
|
+
from . import youtube
|
|
8
|
+
from . import player
|
|
9
|
+
from ..tui import print as tprint
|
|
10
|
+
|
|
11
|
+
_last_results = []
|
|
12
|
+
_last_played = None
|
|
13
|
+
|
|
14
|
+
COMMANDS = {
|
|
15
|
+
"play": "Play a song from YouTube",
|
|
16
|
+
"search": "Search YouTube for tracks",
|
|
17
|
+
"like": "Like a song",
|
|
18
|
+
"download": "Download audio from YouTube",
|
|
19
|
+
"switch": "Switch to Offline mode",
|
|
20
|
+
"help": "Show this help message",
|
|
21
|
+
"exit": "Exit Flow",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
m = tprint(color="grey", border="none")
|
|
25
|
+
e = tprint(color="red", border="none")
|
|
26
|
+
i = tprint(color="theme", border="none")
|
|
27
|
+
|
|
28
|
+
def run(cmd: str, extra: list[str], args):
|
|
29
|
+
extra, args = merge_flags(extra, args)
|
|
30
|
+
if cmd == "play":
|
|
31
|
+
play(extra, args)
|
|
32
|
+
elif cmd == "search":
|
|
33
|
+
search(" ".join(extra) if extra else "")
|
|
34
|
+
elif cmd == "like":
|
|
35
|
+
like_track()
|
|
36
|
+
elif cmd == "download":
|
|
37
|
+
download(extra)
|
|
38
|
+
elif cmd == "switch":
|
|
39
|
+
switch_mode()
|
|
40
|
+
elif cmd == "help":
|
|
41
|
+
show_help()
|
|
42
|
+
else:
|
|
43
|
+
print(f"Unknown command: {cmd}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _spinner(stop):
|
|
47
|
+
chars = "|/-\\"
|
|
48
|
+
i = 0
|
|
49
|
+
while not stop():
|
|
50
|
+
sys.stdout.write(f"\r\x1b[36mSearching... {chars[i]}\x1b[0m")
|
|
51
|
+
sys.stdout.flush()
|
|
52
|
+
time.sleep(0.1)
|
|
53
|
+
i = (i + 1) % len(chars)
|
|
54
|
+
sys.stdout.write("\r" + " " * 40 + "\r")
|
|
55
|
+
sys.stdout.flush()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _do_search(query):
|
|
59
|
+
global _last_results
|
|
60
|
+
stop = False
|
|
61
|
+
t = threading.Thread(target=_spinner, args=(lambda: stop,), daemon=True)
|
|
62
|
+
t.start()
|
|
63
|
+
_last_results = youtube.search(query)
|
|
64
|
+
stop = True
|
|
65
|
+
t.join()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def play(extra: list[str], args):
|
|
69
|
+
global _last_results, _last_played
|
|
70
|
+
arg = " ".join(extra) if extra else None
|
|
71
|
+
if not arg:
|
|
72
|
+
print("No song specified")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if arg == "liked":
|
|
76
|
+
_play_liked(args)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if arg.isdigit():
|
|
80
|
+
idx = int(arg) - 1
|
|
81
|
+
if idx < 0 or idx >= len(_last_results):
|
|
82
|
+
print("Index out of range")
|
|
83
|
+
return
|
|
84
|
+
entry, title, _ = _last_results[idx]
|
|
85
|
+
else:
|
|
86
|
+
_do_search(arg)
|
|
87
|
+
if not _last_results:
|
|
88
|
+
print("No results found")
|
|
89
|
+
return
|
|
90
|
+
entry, title, _ = _last_results[0]
|
|
91
|
+
|
|
92
|
+
_last_played = (entry, title)
|
|
93
|
+
filepath = None
|
|
94
|
+
if getattr(args, "d", False):
|
|
95
|
+
url = entry.get("webpage_url") or entry.get("original_url")
|
|
96
|
+
if not url:
|
|
97
|
+
print("No URL found for this entry")
|
|
98
|
+
return
|
|
99
|
+
m(f" \n Downloading {title}...")
|
|
100
|
+
filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
|
|
101
|
+
m(f" Downloaded to {filepath}")
|
|
102
|
+
|
|
103
|
+
player.play_entry(entry, title, args, filepath)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _play_liked(args):
|
|
107
|
+
if not config.liked_music.exists():
|
|
108
|
+
e(" No liked songs yet")
|
|
109
|
+
return
|
|
110
|
+
liked = config.liked_music.read_text().strip().splitlines()
|
|
111
|
+
if not liked:
|
|
112
|
+
e(" No liked songs yet")
|
|
113
|
+
return
|
|
114
|
+
if getattr(args, "s", False):
|
|
115
|
+
random.shuffle(liked)
|
|
116
|
+
for line in liked:
|
|
117
|
+
if "|" not in line:
|
|
118
|
+
continue
|
|
119
|
+
title, url = line.split("|", 1)
|
|
120
|
+
_do_search(title)
|
|
121
|
+
if not _last_results:
|
|
122
|
+
m(f" Skipping {title} (not found)")
|
|
123
|
+
continue
|
|
124
|
+
entry, _, _ = _last_results[0]
|
|
125
|
+
_last_played = (entry, title)
|
|
126
|
+
player.play_entry(entry, title, args)
|
|
127
|
+
|
|
128
|
+
def like_track():
|
|
129
|
+
global _last_played
|
|
130
|
+
if not _last_played:
|
|
131
|
+
e(" No song currently playing")
|
|
132
|
+
return
|
|
133
|
+
entry, title = _last_played
|
|
134
|
+
url = entry.get("webpage_url") or entry.get("original_url")
|
|
135
|
+
if not url:
|
|
136
|
+
e(" No URL for current song")
|
|
137
|
+
return
|
|
138
|
+
config.liked_music.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
existing = config.liked_music.read_text().strip().splitlines() if config.liked_music.exists() else []
|
|
140
|
+
if any(title in line for line in existing):
|
|
141
|
+
m(f" {title} already liked")
|
|
142
|
+
return
|
|
143
|
+
with open(config.liked_music, "a") as f:
|
|
144
|
+
f.write(f"{title}|{url}\n")
|
|
145
|
+
i(f" Liked: {title}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def search(query: str):
|
|
149
|
+
global _last_results
|
|
150
|
+
if not query:
|
|
151
|
+
print("Search query required")
|
|
152
|
+
return
|
|
153
|
+
_do_search(query)
|
|
154
|
+
if not _last_results:
|
|
155
|
+
print("No results found")
|
|
156
|
+
return
|
|
157
|
+
for i, (_, title, dur) in enumerate(_last_results, 1):
|
|
158
|
+
mins, secs = divmod(int(dur), 60)
|
|
159
|
+
m(f" {i}. {title} ({mins}:{secs:02d})")
|
|
160
|
+
|
|
161
|
+
def download(extra: list[str]):
|
|
162
|
+
global _last_results
|
|
163
|
+
arg = " ".join(extra) if extra else None
|
|
164
|
+
if not arg:
|
|
165
|
+
e("No song specified")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
if arg.isdigit():
|
|
169
|
+
idx = int(arg) - 1
|
|
170
|
+
if idx < 0 or idx >= len(_last_results):
|
|
171
|
+
e(" Index out of range")
|
|
172
|
+
return
|
|
173
|
+
entry, _, _ = _last_results[idx]
|
|
174
|
+
url = entry.get("webpage_url") or entry.get("original_url")
|
|
175
|
+
if not url:
|
|
176
|
+
e(" No URL found for this entry")
|
|
177
|
+
return
|
|
178
|
+
title = entry.get("title", "Unknown")
|
|
179
|
+
filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
|
|
180
|
+
i(f" Downloaded: {title} -> {filepath}")
|
|
181
|
+
else:
|
|
182
|
+
_do_search(arg)
|
|
183
|
+
if not _last_results:
|
|
184
|
+
e(" No results found")
|
|
185
|
+
return
|
|
186
|
+
entry, _, _ = _last_results[0]
|
|
187
|
+
url = entry.get("webpage_url") or entry.get("original_url")
|
|
188
|
+
if not url:
|
|
189
|
+
e(" No URL found for this entry")
|
|
190
|
+
return
|
|
191
|
+
title = entry.get("title", "Unknown")
|
|
192
|
+
filepath = youtube.download_url(url, config.DOWNLOAD_DIR)
|
|
193
|
+
i(f" Downloaded: {title} -> {filepath}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def switch_mode():
|
|
197
|
+
config.Mode = "Offline"
|
|
198
|
+
config.THEME = config.OFFLINE_THEME
|
|
199
|
+
m("Switched to Offline mode")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def show_help():
|
|
203
|
+
print(f"{config.THEMES[config.THEME]['theme']}Online Commands:{chr(27)}[0m")
|
|
204
|
+
for cmd, desc in COMMANDS.items():
|
|
205
|
+
print(f" {cmd:12s} {desc}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
import vlc
|
|
4
|
+
from ..tui import print as tprint
|
|
5
|
+
|
|
6
|
+
m = tprint(color="grey", border="none")
|
|
7
|
+
e = tprint(color="red", border="none")
|
|
8
|
+
i = tprint(color="theme", border="none")
|
|
9
|
+
|
|
10
|
+
BAR_WIDTH = 40
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _progress_bar(elapsed, total):
|
|
14
|
+
if total <= 0:
|
|
15
|
+
return ""
|
|
16
|
+
fraction = min(elapsed / total, 1.0)
|
|
17
|
+
filled = int(fraction * BAR_WIDTH)
|
|
18
|
+
bar = "/" * filled + " " * (BAR_WIDTH - filled)
|
|
19
|
+
pct = int(fraction * 100)
|
|
20
|
+
return f" [{bar}] {pct}%"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def play_entry(entry, title, args=None, filepath=None):
|
|
24
|
+
repeat = args and getattr(args, "r", False)
|
|
25
|
+
|
|
26
|
+
while True:
|
|
27
|
+
instance = vlc.Instance("--no-video --quiet")
|
|
28
|
+
player = instance.media_player_new()
|
|
29
|
+
|
|
30
|
+
if filepath:
|
|
31
|
+
media = instance.media_new(filepath)
|
|
32
|
+
else:
|
|
33
|
+
stream_url = None
|
|
34
|
+
for fmt in reversed(entry.get("formats", [])):
|
|
35
|
+
if fmt.get("acodec") != "none":
|
|
36
|
+
stream_url = fmt["url"]
|
|
37
|
+
break
|
|
38
|
+
if not stream_url:
|
|
39
|
+
e(" No playable stream found")
|
|
40
|
+
return
|
|
41
|
+
media = instance.media_new(stream_url)
|
|
42
|
+
|
|
43
|
+
player.set_media(media)
|
|
44
|
+
player.play()
|
|
45
|
+
|
|
46
|
+
duration = entry.get("duration", 0)
|
|
47
|
+
dur_min, dur_sec = divmod(int(duration), 60)
|
|
48
|
+
i(f"\nPlaying : {title}")
|
|
49
|
+
m(f" {dur_min}:{dur_sec:02d} | repeat:{args.r} | shuffle:{args.s}")
|
|
50
|
+
|
|
51
|
+
start = time.time()
|
|
52
|
+
try:
|
|
53
|
+
while player.get_state() not in (vlc.State.Ended, vlc.State.Error):
|
|
54
|
+
elapsed = time.time() - start
|
|
55
|
+
bar = _progress_bar(elapsed, duration)
|
|
56
|
+
sys.stdout.write(f"\r\x1b[36m{bar}\x1b[0m")
|
|
57
|
+
sys.stdout.flush()
|
|
58
|
+
time.sleep(0.5)
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
player.stop()
|
|
61
|
+
sys.stdout.write("\n")
|
|
62
|
+
sys.stdout.flush()
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
sys.stdout.write("\n")
|
|
66
|
+
sys.stdout.flush()
|
|
67
|
+
|
|
68
|
+
if not repeat:
|
|
69
|
+
break
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import yt_dlp
|
|
2
|
+
|
|
3
|
+
ydl_opts = {
|
|
4
|
+
"quiet": True, "no_warnings": True, "noprogress": True,
|
|
5
|
+
"noplaylist": True, "format": "bestaudio/best",
|
|
6
|
+
"default_search": "ytsearch1", "skip_download": True,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
ydl_opts_dwn = {
|
|
10
|
+
"quiet": True, "no_warnings": True, "noprogress": True,
|
|
11
|
+
"noplaylist": True, "format": "bestaudio/best",
|
|
12
|
+
"outtmpl": "downloads/%(title)s.%(ext)s",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def search(query, limit=5):
|
|
16
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
17
|
+
info = ydl.extract_info(f"ytsearch{limit}:{query}", download=False)
|
|
18
|
+
if not info.get("entries"):
|
|
19
|
+
return []
|
|
20
|
+
results = []
|
|
21
|
+
for entry in info["entries"]:
|
|
22
|
+
title = entry.get("title", "Unknown")
|
|
23
|
+
dur = entry.get("duration", 0)
|
|
24
|
+
results.append((entry, title, dur))
|
|
25
|
+
return results
|
|
26
|
+
|
|
27
|
+
def download_url(url, outdir):
|
|
28
|
+
opts = {**ydl_opts_dwn, "outtmpl": f"{outdir}/%(title)s.%(ext)s"}
|
|
29
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
30
|
+
info = ydl.extract_info(url, download=True)
|
|
31
|
+
return ydl.prepare_filename(info)
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
def merge_flags(extra: list[str], args) -> tuple[list[str], object]:
|
|
4
|
+
mapping = {"-r": "r", "-s": "s", "-d": "d"}
|
|
5
|
+
rest = []
|
|
6
|
+
for item in extra:
|
|
7
|
+
if item in mapping:
|
|
8
|
+
setattr(args, mapping[item], True)
|
|
9
|
+
elif item.startswith("-"):
|
|
10
|
+
print(f"Unknown flag: {item}")
|
|
11
|
+
else:
|
|
12
|
+
rest.append(item)
|
|
13
|
+
return rest, args
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ONLINE_THEME = "cyan"
|
|
17
|
+
OFFLINE_THEME = "magenta"
|
|
18
|
+
|
|
19
|
+
THEMES = {
|
|
20
|
+
"blue": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[34m"},
|
|
21
|
+
"green": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[32m"},
|
|
22
|
+
"red": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[31m"},
|
|
23
|
+
"yellow": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[33m"},
|
|
24
|
+
"magenta": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[35m"},
|
|
25
|
+
"cyan": {"white": "\033[37m", "grey": "\033[90m", "theme": "\033[36m"},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
DOWNLOAD_DIR = pathlib.Path.home() / ".flow/downloads"
|
|
29
|
+
|
|
30
|
+
VERSION = 0.1
|
|
31
|
+
|
|
32
|
+
Mode = "Online"
|
|
33
|
+
|
|
34
|
+
THEME = ONLINE_THEME
|
|
35
|
+
|
|
36
|
+
liked_music = pathlib.Path.home() / ".flow/liked.txt"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import importlib
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from . import config
|
|
7
|
+
from .tui import show_banner, input as tui_input, print as tui_print
|
|
8
|
+
from .ping import is_connected
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _spinner(stop):
|
|
12
|
+
chars = "|/-\\"
|
|
13
|
+
i = 0
|
|
14
|
+
while not stop():
|
|
15
|
+
sys.stdout.write(f"\r\x1b[36mChecking connection... {chars[i]}\x1b[0m")
|
|
16
|
+
sys.stdout.flush()
|
|
17
|
+
time.sleep(0.1)
|
|
18
|
+
i = (i + 1) % len(chars)
|
|
19
|
+
sys.stdout.write("\r" + " " * 40 + "\r")
|
|
20
|
+
sys.stdout.flush()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_commands():
|
|
24
|
+
return importlib.import_module(f".{config.Mode}.commands", package=__package__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
stop = False
|
|
29
|
+
t = threading.Thread(target=_spinner, args=(lambda: stop,), daemon=True)
|
|
30
|
+
t.start()
|
|
31
|
+
try:
|
|
32
|
+
is_connected()
|
|
33
|
+
finally:
|
|
34
|
+
stop = True
|
|
35
|
+
t.join()
|
|
36
|
+
|
|
37
|
+
parser = argparse.ArgumentParser(description="Flow Music Player")
|
|
38
|
+
parser.add_argument("-r", action="store_true", help="repeat mode")
|
|
39
|
+
parser.add_argument("-s", action="store_true", help="shuffle")
|
|
40
|
+
parser.add_argument("-d", action="store_true", help="download mode")
|
|
41
|
+
parser.add_argument("command", nargs="?", default=None, help="subcommand (play, search, list, ...)")
|
|
42
|
+
|
|
43
|
+
args, unknown = parser.parse_known_args()
|
|
44
|
+
|
|
45
|
+
commands = _load_commands()
|
|
46
|
+
|
|
47
|
+
show_banner()
|
|
48
|
+
|
|
49
|
+
if args.command:
|
|
50
|
+
commands.run(args.command, unknown, args)
|
|
51
|
+
elif unknown:
|
|
52
|
+
tui_print(color="white", border="double")(f"Unknown argument: {' '.join(unknown)}")
|
|
53
|
+
else:
|
|
54
|
+
while True:
|
|
55
|
+
try:
|
|
56
|
+
parts = tui_input().strip().split()
|
|
57
|
+
if not parts:
|
|
58
|
+
continue
|
|
59
|
+
cmd = parts[0].lower()
|
|
60
|
+
extra = parts[1:]
|
|
61
|
+
if cmd in ("exit", "quit", "q"):
|
|
62
|
+
tui_print(color="grey", border="none")("Goodbye!")
|
|
63
|
+
break
|
|
64
|
+
cmd_args = argparse.Namespace(**vars(args))
|
|
65
|
+
for flag in ("r", "s", "d"):
|
|
66
|
+
setattr(cmd_args, flag, False)
|
|
67
|
+
commands.run(cmd, extra, cmd_args)
|
|
68
|
+
if cmd == "switch":
|
|
69
|
+
mode = config.Mode
|
|
70
|
+
commands = _load_commands()
|
|
71
|
+
show_banner()
|
|
72
|
+
tui_print(color="grey", border="none")(f"Mode changed to {mode}")
|
|
73
|
+
except (EOFError, KeyboardInterrupt):
|
|
74
|
+
tui_print(color="grey", border="none")("\nGoodbye!")
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import urllib.request
|
|
3
|
+
from . import config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_connected(timeout: int = 2, hosts: list[str] | None = None) -> bool:
|
|
7
|
+
if hosts is None:
|
|
8
|
+
hosts = ["1.1.1.1"]
|
|
9
|
+
|
|
10
|
+
socket.setdefaulttimeout(timeout)
|
|
11
|
+
|
|
12
|
+
for host in hosts:
|
|
13
|
+
try:
|
|
14
|
+
with urllib.request.urlopen(f"https://{host}", timeout=timeout):
|
|
15
|
+
pass
|
|
16
|
+
config.Mode = "Online"
|
|
17
|
+
config.THEME = config.ONLINE_THEME
|
|
18
|
+
return True
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
socket.create_connection((host, 80), timeout=timeout).close()
|
|
24
|
+
config.Mode = "Online"
|
|
25
|
+
config.THEME = config.ONLINE_THEME
|
|
26
|
+
return True
|
|
27
|
+
except Exception:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
config.Mode = "Offline"
|
|
31
|
+
config.THEME = config.OFFLINE_THEME
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# if __name__ == "__main__":
|
|
36
|
+
# connected = is_connected()
|
|
37
|
+
# print(f"Status : {config.Mode}")
|
|
38
|
+
# print(f"Returns: {connected}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
from . import config
|
|
3
|
+
|
|
4
|
+
RESET = "\033[0m"
|
|
5
|
+
|
|
6
|
+
BORDER_CHARS = {
|
|
7
|
+
"none": None,
|
|
8
|
+
"single": ("-", "|", "+"),
|
|
9
|
+
"double": ("=", "|", "+"),
|
|
10
|
+
"dashed": ("- ", "!", "+"),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _color_map():
|
|
15
|
+
colors = config.THEMES.get(config.THEME, config.THEMES["cyan"])
|
|
16
|
+
return {
|
|
17
|
+
"white": colors["white"],
|
|
18
|
+
"grey": colors["grey"],
|
|
19
|
+
"theme": colors["theme"],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def print(color: str = "white", border: str = "none"):
|
|
24
|
+
def styled_print(text: str):
|
|
25
|
+
cm = _color_map()
|
|
26
|
+
color_code = cm.get(color.lower(), cm["white"])
|
|
27
|
+
border_style = BORDER_CHARS.get(border.lower())
|
|
28
|
+
|
|
29
|
+
if border_style is None:
|
|
30
|
+
print_line = f"{color_code}{text}{RESET}"
|
|
31
|
+
builtins.print(print_line)
|
|
32
|
+
else:
|
|
33
|
+
h_char, v_char, corner_char = border_style
|
|
34
|
+
lines = text.split("\n")
|
|
35
|
+
max_w = max(len(l) for l in lines)
|
|
36
|
+
width = max_w + 2
|
|
37
|
+
top_bottom = corner_char + h_char * width + corner_char
|
|
38
|
+
builtins.print(f"{color_code}{top_bottom}{RESET}")
|
|
39
|
+
for line in lines:
|
|
40
|
+
padded = line.ljust(max_w)
|
|
41
|
+
builtins.print(f"{color_code}{v_char} {padded} {v_char}{RESET}")
|
|
42
|
+
builtins.print(f"{color_code}{top_bottom}{RESET}")
|
|
43
|
+
|
|
44
|
+
return styled_print
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
BANNER = r"""
|
|
48
|
+
███████╗██╗ ██████╗ ██╗ ██╗
|
|
49
|
+
██╔════╝██║ ██╔═══██╗██║ ██║
|
|
50
|
+
█████╗ ██║ ██║ ██║██║ █╗ ██║
|
|
51
|
+
██╔══╝ ██║ ██║ ██║██║███╗██║
|
|
52
|
+
██║ ███████╗╚██████╔╝╚███╔███╔╝
|
|
53
|
+
╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def show_banner():
|
|
58
|
+
p = print(color="theme", border="none")
|
|
59
|
+
p(BANNER)
|
|
60
|
+
p = print(color="grey", border="none")
|
|
61
|
+
p(f" Flow Music Player v{config.VERSION}")
|
|
62
|
+
p = print(color="theme", border="none")
|
|
63
|
+
p(f" Mode : {config.Mode}")
|
|
64
|
+
builtins.print()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def input(prompt: str = "") -> str:
|
|
68
|
+
cm = _color_map()
|
|
69
|
+
return builtins.input(f"{cm['theme']}{prompt}$ {RESET}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flow-twinx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal music player with online and offline modes
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: python-vlc
|
|
8
|
+
Requires-Dist: yt_dlp
|
|
9
|
+
|
|
10
|
+
# Flow
|
|
11
|
+
|
|
12
|
+
A terminal-based music player with online streaming and offline library modes.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Dual-mode operation** — Automatically detects internet and switches between online streaming and offline playback
|
|
17
|
+
- **Online mode** — Search and stream audio from YouTube via `yt-dlp` and `python-vlc`
|
|
18
|
+
- **Offline mode** — Play local audio files with album support, search, and a liked-songs collection
|
|
19
|
+
- **Download** — Save tracks from YouTube to your local library with the `-d` flag
|
|
20
|
+
- **Repeat & shuffle** — Loop tracks or play in random order
|
|
21
|
+
- **Like/unlike** — Save favorites to a dedicated playlist
|
|
22
|
+
- **Tab completion** — Auto-complete commands and song names in offline mode
|
|
23
|
+
- **Colored TUI** — Cyan theme for online, magenta for offline, with borders and banners
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- Python 3
|
|
28
|
+
- [VLC](https://www.videolan.org/vlc/) media player (for `python-vlc` bindings)
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/yourusername/flow.git
|
|
34
|
+
cd flow
|
|
35
|
+
python -m venv .venv
|
|
36
|
+
.venv\Scripts\pip install -r requirements.txt
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or use the installer script:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bash install.sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python main.py [-r] [-s] [-d]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or direct if you have used install.sh
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
flow <command> <flag>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Flag | Description |
|
|
58
|
+
| ---- | ---------------------------------------------- |
|
|
59
|
+
| `-r` | Repeat mode (loop current track) |
|
|
60
|
+
| `-s` | Shuffle mode (random order) |
|
|
61
|
+
| `-d` | Download mode (save streamed audio to library) |
|
|
62
|
+
|
|
63
|
+
### Commands
|
|
64
|
+
|
|
65
|
+
| Command | Description |
|
|
66
|
+
| ---------------------- | -------------------------------------------- |
|
|
67
|
+
| `play <name or #>` | Play a song by name or search result number |
|
|
68
|
+
| `search <query>` | Search YouTube (online) or library (offline) |
|
|
69
|
+
| `list` | Show all songs, albums, or liked tracks |
|
|
70
|
+
| `like <name or #>` | Add a song to your liked collection |
|
|
71
|
+
| `download <name or #>` | Save a streamed song to the local library |
|
|
72
|
+
| `switch` | Toggle between online and offline mode |
|
|
73
|
+
| `help` | Show available commands |
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
- Downloads and library are stored in `~/.flow/downloads/`
|
|
78
|
+
- Liked songs (online) are saved to `~/.flow/liked.txt`
|
|
79
|
+
- Liked songs (offline) are copied to `~/.flow/downloads/liked songs/`
|
|
80
|
+
|
|
81
|
+
## Project Structure
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Flow/
|
|
85
|
+
├── config.py # Configuration and theme settings
|
|
86
|
+
├── main.py # Entry point and REPL loop
|
|
87
|
+
├── ping.py # Internet connectivity check
|
|
88
|
+
├── tui.py # Terminal UI helpers
|
|
89
|
+
├── requirements.txt # Python dependencies
|
|
90
|
+
├── install.sh # Installer script
|
|
91
|
+
├── Online/
|
|
92
|
+
│ ├── commands.py # Online mode commands
|
|
93
|
+
│ ├── player.py # VLC streaming player
|
|
94
|
+
│ └── youtube.py # YouTube search and download
|
|
95
|
+
└── Offline/
|
|
96
|
+
├── commands.py # Offline mode commands
|
|
97
|
+
├── file.py # Library scanner and search
|
|
98
|
+
└── player.py # Local file player
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
requirements.txt
|
|
4
|
+
flow_twinx/__init__.py
|
|
5
|
+
flow_twinx/config.py
|
|
6
|
+
flow_twinx/main.py
|
|
7
|
+
flow_twinx/ping.py
|
|
8
|
+
flow_twinx/tui.py
|
|
9
|
+
flow_twinx.egg-info/PKG-INFO
|
|
10
|
+
flow_twinx.egg-info/SOURCES.txt
|
|
11
|
+
flow_twinx.egg-info/dependency_links.txt
|
|
12
|
+
flow_twinx.egg-info/entry_points.txt
|
|
13
|
+
flow_twinx.egg-info/requires.txt
|
|
14
|
+
flow_twinx.egg-info/top_level.txt
|
|
15
|
+
flow_twinx/Offline/__init__.py
|
|
16
|
+
flow_twinx/Offline/commands.py
|
|
17
|
+
flow_twinx/Offline/file.py
|
|
18
|
+
flow_twinx/Offline/player.py
|
|
19
|
+
flow_twinx/Offline/youtube.py
|
|
20
|
+
flow_twinx/Online/__init__.py
|
|
21
|
+
flow_twinx/Online/commands.py
|
|
22
|
+
flow_twinx/Online/player.py
|
|
23
|
+
flow_twinx/Online/youtube.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flow_twinx
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flow-twinx"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A terminal music player with online and offline modes"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dynamic = ["dependencies"]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
flow = "flow_twinx.main:main"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.dynamic]
|
|
17
|
+
dependencies = { file = ["requirements.txt"] }
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["."]
|
|
21
|
+
include = ["flow_twinx*"]
|