215 lines
6.6 KiB
Python
215 lines
6.6 KiB
Python
|
# note: On Windows, pip install windows-curses
|
||
|
from __future__ import annotations
|
||
|
from urllib.request import urlopen
|
||
|
from json import loads as loadJson
|
||
|
from webbrowser import open_new_tab as browserOpen
|
||
|
from typing import List
|
||
|
from re import search as searchRegex
|
||
|
from pickle import load as pickleLoad, dump as pickleDump
|
||
|
from os.path import join as pathJoin, exists as pathExists
|
||
|
from sys import path as sysPath
|
||
|
import curses
|
||
|
|
||
|
class InvalidID(Exception): pass
|
||
|
|
||
|
class Playlist:
|
||
|
def __init__(self, nickname: str):
|
||
|
self.__nickname = nickname
|
||
|
self.__videos: List[Video] = []
|
||
|
self.__file = Playlist.getPath(nickname)
|
||
|
|
||
|
if pathExists(self.__file):
|
||
|
with open(self.__file, "rb") as f:
|
||
|
self.__videos = pickleLoad(f)
|
||
|
|
||
|
@staticmethod
|
||
|
def getPath(nickname: str) -> str:
|
||
|
return pathJoin(sysPath[0], f"{nickname}.playlist")
|
||
|
|
||
|
def getName(self) -> str:
|
||
|
return self.__nickname
|
||
|
|
||
|
def addVideo(self, video: Video):
|
||
|
self.__videos.append(video)
|
||
|
return self.__save()
|
||
|
|
||
|
def __save(self):
|
||
|
with open(self.__file, "wb") as f:
|
||
|
pickleDump(self.__videos, f)
|
||
|
|
||
|
return self
|
||
|
|
||
|
def __getitem__(self, index: int):
|
||
|
if index > len(self.__videos) - 1:
|
||
|
raise IndexError("Out of bounds")
|
||
|
|
||
|
return self.__videos[index]
|
||
|
|
||
|
def __delitem__(self, index: int):
|
||
|
if index > len(self.__videos) - 1:
|
||
|
raise IndexError("Out of bounds")
|
||
|
|
||
|
del self.__videos[index]
|
||
|
self.__save()
|
||
|
|
||
|
def __len__(self):
|
||
|
return len(self.__videos)
|
||
|
|
||
|
class Video:
|
||
|
def __init__(self, id: str):
|
||
|
self.__id = id
|
||
|
self.__url = f"https://youtu.be/{self.__id}"
|
||
|
|
||
|
try:
|
||
|
with urlopen(f"https://www.youtube.com/oembed?url=https://youtu.be/{self.__id}&format=json") as response:
|
||
|
data = response.read()
|
||
|
|
||
|
data = loadJson(data)
|
||
|
self.__title = data["title"]
|
||
|
self.__author = data["author_name"]
|
||
|
except:
|
||
|
raise InvalidID(f"{id} is not a valid ID!")
|
||
|
|
||
|
def __str__(self):
|
||
|
return f"{self.__author} - {self.__title}"
|
||
|
|
||
|
def open(self):
|
||
|
browserOpen(self.__url)
|
||
|
|
||
|
# Initialise curses
|
||
|
screen = curses.initscr()
|
||
|
screen.keypad(True)
|
||
|
curses.cbreak()
|
||
|
curses.noecho()
|
||
|
|
||
|
# Initialise colours
|
||
|
curses.start_color()
|
||
|
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||
|
curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK)
|
||
|
curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK)
|
||
|
|
||
|
black, white, red = curses.color_pair(1), curses.color_pair(2), curses.color_pair(3)
|
||
|
|
||
|
def selectMenu(screen, options: List[str]):
|
||
|
selectedIndex = 0
|
||
|
optionCount = len(options)
|
||
|
|
||
|
while True:
|
||
|
screen.clear()
|
||
|
screen.addstr("Pick an option:\n\n", curses.A_BOLD)
|
||
|
|
||
|
for i in range(optionCount):
|
||
|
screen.addstr(f"{i + 1}. ")
|
||
|
screen.addstr(f"{options[i]}\n", black if i == selectedIndex else white)
|
||
|
|
||
|
c = screen.getch()
|
||
|
|
||
|
if c == curses.KEY_UP or c == curses.KEY_LEFT:
|
||
|
selectedIndex -= 1 - optionCount
|
||
|
selectedIndex %= optionCount
|
||
|
|
||
|
elif c == curses.KEY_DOWN or c == curses.KEY_RIGHT:
|
||
|
selectedIndex += 1
|
||
|
selectedIndex %= optionCount
|
||
|
|
||
|
elif c == curses.KEY_ENTER or chr(c) in '\n\r':
|
||
|
return selectedIndex + 1
|
||
|
|
||
|
def addVideo(screen, playlist: Playlist):
|
||
|
hasErrored = False
|
||
|
|
||
|
while True:
|
||
|
screen.clear()
|
||
|
screen.addstr(0, 0, "Please enter a YouTube URL/ID:", curses.A_BOLD)
|
||
|
|
||
|
# Display the error message if necessary
|
||
|
if hasErrored:
|
||
|
screen.addstr(3, 0, "Please make sure you have entered a valid URL/ID.", red | curses.A_BOLD)
|
||
|
|
||
|
# Fetch the inputted data
|
||
|
curses.echo()
|
||
|
videoId = screen.getstr(1, 0, 50).decode("utf-8")
|
||
|
curses.noecho()
|
||
|
|
||
|
# Attempt to extract a video ID
|
||
|
parsed = searchRegex(r"(?:youtu\.be\/|youtube\.com(?:\/embed\/|\/v\/|\/watch\?v=|\/user\/\S+|\/ytscreeningroom\?v=))([\w\-]{10,12})\b", videoId)
|
||
|
|
||
|
if parsed:
|
||
|
videoId = parsed.group(1)
|
||
|
|
||
|
# Attempt to add the video to the playlist
|
||
|
try:
|
||
|
video = Video(str(videoId))
|
||
|
playlist.addVideo(video)
|
||
|
|
||
|
break
|
||
|
except InvalidID:
|
||
|
hasErrored = True
|
||
|
|
||
|
def viewPlaylist(screen, playlist: Playlist):
|
||
|
selectedIndex = 0
|
||
|
selectedPlay = True
|
||
|
playlistLength = len(playlist)
|
||
|
|
||
|
while True:
|
||
|
screen.clear()
|
||
|
screen.addstr(f"{playlist.getName()} playlist\n", curses.A_BOLD)
|
||
|
screen.addstr("Press the backspace key to exit this menu.\n\n")
|
||
|
|
||
|
if playlistLength == 0:
|
||
|
screen.addstr("There is nothing here!\n")
|
||
|
c = screen.getch()
|
||
|
else:
|
||
|
for i in range(playlistLength):
|
||
|
screen.addstr(f"{i + 1}. {playlist[i]} ")
|
||
|
screen.addstr("[PLAY]", black if selectedPlay and selectedIndex == i else white)
|
||
|
screen.addstr(" ")
|
||
|
screen.addstr("[DELETE]\n", black if not selectedPlay and selectedIndex == i else white)
|
||
|
|
||
|
c = screen.getch()
|
||
|
|
||
|
if c == curses.KEY_UP:
|
||
|
selectedIndex -= 1 - playlistLength
|
||
|
selectedIndex %= playlistLength
|
||
|
|
||
|
elif c == curses.KEY_DOWN:
|
||
|
selectedIndex += 1
|
||
|
selectedIndex %= playlistLength
|
||
|
|
||
|
elif c == curses.KEY_LEFT:
|
||
|
selectedPlay = True
|
||
|
|
||
|
elif c == curses.KEY_RIGHT:
|
||
|
selectedPlay = False
|
||
|
|
||
|
elif c == curses.KEY_ENTER or chr(c) in '\n\r':
|
||
|
if selectedPlay:
|
||
|
playlist[selectedIndex].open()
|
||
|
else:
|
||
|
del playlist[selectedIndex]
|
||
|
playlistLength -= 1
|
||
|
selectedIndex = 0 if selectedIndex == 0 else selectedIndex - 1
|
||
|
|
||
|
if c == 8 or c == 127 or c == curses.KEY_BACKSPACE:
|
||
|
break
|
||
|
|
||
|
# todo: add custom playlist names
|
||
|
|
||
|
if __name__ == "__main__":
|
||
|
playlist = Playlist("main")
|
||
|
|
||
|
while True:
|
||
|
choice = selectMenu(screen, ["Add a video to the playlist", "View the playlist", "Exit"])
|
||
|
|
||
|
if choice == 1:
|
||
|
addVideo(screen, playlist)
|
||
|
elif choice == 2:
|
||
|
viewPlaylist(screen, playlist)
|
||
|
elif choice == 3:
|
||
|
break
|
||
|
|
||
|
# Exit curses
|
||
|
curses.nocbreak()
|
||
|
screen.keypad(False)
|
||
|
curses.echo()
|
||
|
curses.endwin()
|