Compare commits

..

40 Commits

Author SHA1 Message Date
alacleaker 6d7cb3ada4 update description 2024-04-23 00:32:22 -03:00
alacleaker b87d406ffa update requirements 2024-04-23 00:31:40 -03:00
alacleaker f6efdb3332 Update README.md 2024-04-23 00:19:59 -03:00
alacleaker 29a006c304 update --read-urls-as-txt help 2024-04-23 00:19:16 -03:00
alacleaker a49a9c90cc read all url arguments instead of just the first when -r 2024-04-23 00:16:27 -03:00
alacleaker 43fd1dd2e3 Update README.md 2024-04-23 00:01:27 -03:00
alacleaker 2ed6ac05ba Update README.md 2024-04-23 00:00:25 -03:00
alacleaker 0f0e17f4cd adjust inquirer message 2024-04-22 23:56:36 -03:00
alacleaker 8c4d2713f7 switch from fstring to string 2024-04-22 23:37:42 -03:00
alacleaker 1baca4151b switch to InquirerPy in get_stream_url_from_user 2024-04-22 23:36:50 -03:00
alacleaker 6f08a4b2f9 adjust select_albums_from_artist 2024-04-22 23:12:56 -03:00
alacleaker 38f708e2e9 add validation to get_download_queue_from_artist 2024-04-22 23:11:25 -03:00
alacleaker f27adf98df add column indication when listing artist mvs/albums 2024-04-22 23:01:52 -03:00
alacleaker 9f0b25e1d1 add rating info when listing artist mvs 2024-04-22 22:57:27 -03:00
alacleaker 3590d99063 add rating info when listing artist albums 2024-04-22 22:55:18 -03:00
alacleaker b200dade5a change checking url log from debug to info 2024-04-22 21:12:23 -03:00
alacleaker 118d23e9db remove _best prefix from music_video_codec enum 2024-04-22 20:41:39 -03:00
alacleaker 7bc8c6668f bump version 2024-04-22 20:37:42 -03:00
alacleaker 0e9fb3702d adjust getting streaminfo log 2024-04-22 20:26:21 -03:00
alacleaker e706f0fa82 switch from tabulate to InquirerPy for selecting codecs 2024-04-22 20:24:37 -03:00
alacleaker 3014fb112d add support for artist urls 2024-04-22 20:05:15 -03:00
alacleaker d7f17b8b6f add debug message for url checking 2024-04-22 20:04:50 -03:00
alacleaker 947e2df81a rename some variables 2024-04-22 17:02:48 -03:00
alacleaker c8fe96b31d add get_artist 2024-04-22 13:01:38 -03:00
alacleaker 83a3efc1fa add _extend_api_data and _get_next_uri_response methods 2024-04-22 12:54:28 -03:00
alacleaker 345afbf174 adjust lyrics checking 2024-04-16 21:19:45 -03:00
alacleaker c35051a7ec bump version 2024-04-16 21:13:48 -03:00
alacleaker b286ee84e2 add from __future__ import annotations 2024-04-16 21:12:29 -03:00
alacleaker 9094f2c7b4 remove -> None 2024-04-16 21:09:04 -03:00
alacleaker 5feb5b274a remove "-isma" 2024-04-16 21:07:08 -03:00
alacleaker 1375af929c add keep utc to remux mp4box 2024-04-16 00:37:54 -03:00
alacleaker d280f1fad2 Merge branch 'main' of https://github.com/glomatico/gamdl 2024-04-15 10:43:45 -03:00
alacleaker 3a04d7927e adjust get_lyrics 2024-04-15 10:43:37 -03:00
Rafael Moraes 942a812308 Update README.md 2024-04-14 13:42:48 -03:00
Rafael Moraes 66e01293e6 Update README.md 2024-04-14 13:26:03 -03:00
alacleaker c0561da592 Update README.md 2024-04-13 02:58:04 -03:00
alacleaker 56d238fb1b update description 2024-04-13 02:58:01 -03:00
alacleaker d3a53bf93b Update README.md 2024-04-12 18:15:56 -03:00
alacleaker bb7a3ff77e Update README.md 2024-04-12 18:14:55 -03:00
alacleaker bf6293a0a0 Update README.md 2024-04-12 18:12:05 -03:00
13 changed files with 243 additions and 112 deletions
+13 -6
View File
@@ -1,5 +1,7 @@
# Glomatico's Apple Music Downloader
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
@@ -8,6 +10,7 @@ A Python script to download Apple Music songs/music videos/albums/playlists/post
* Choose between FFmpeg and MP4Box for remuxing
* Choose between yt-dlp and N_m3u8DL-RE for downloading
* Highly customizable
* Use artist links to download all of their albums or music videos
## Prerequisites
* Python 3.8 or higher
@@ -45,6 +48,10 @@ gamdl [OPTIONS] URLS...
```bash
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
```
* Choose which albums or music videos to download from an artist
```bash
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
```
## Configuration
You can configure gamdl by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
@@ -53,7 +60,7 @@ You can configure gamdl by using the command line arguments or the config file.
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
@@ -83,7 +90,7 @@ You can configure gamdl by using the command line arguments or the config file.
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264-best` |
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
| `--quality-post` / `quality_post` | Post video quality. | `best` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
@@ -159,14 +166,14 @@ The following codecs are available:
### Music videos codecs
The following codecs are available:
* `h264-best` (with AAC 256kbps, up to 1080p)
* `h265-best` (With AAC 256kpbs, up to 2160p)
* `h264` (up to 1080p, with AAC 256kbps)
* `h265` (up to 2160p, with AAC 256kpbs)
* `ask`
* When using this option, gamdl will ask you which audio and video codec to use that is available for the music video.
### Post videos/extra videos qualities
The following qualities are available:
* `best` (with AAC 256kbps, up to 1080p)
* `best` (up to 1080p, with AAC 256kbps)
* `ask`
* When using this option, gamdl will ask you which video quality to use that is available for the video.
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.1.7"
__version__ = "2.2"
+45 -16
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import functools
import re
import time
import typing
from http.cookiejar import MozillaCookieJar
from pathlib import Path
@@ -85,6 +86,31 @@ class AppleMusicApi:
):
self._raise_response_exception(response)
def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
limit: int = 100,
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
},
)
self._check_amp_api_response(response)
artist = response.json()["data"][0]
if fetch_all:
for _include in include.split(","):
for additional_data in self._extend_api_data(
artist["relationships"][_include],
limit,
):
artist["relationships"][_include]["data"].extend(additional_data)
return artist
def get_song(
self,
song_id: str,
@@ -146,7 +172,7 @@ class AppleMusicApi:
is_library: bool = False,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
full_playlist: bool = True,
fetch_all: bool = True,
) -> dict:
response = self.session.get(
f"{self.AMP_API_URL}/v1/{'me' if is_library else 'catalog'}/{self.storefront}/playlists/{playlist_id}",
@@ -157,28 +183,31 @@ class AppleMusicApi:
)
self._check_amp_api_response(response)
playlist = response.json()["data"][0]
if full_playlist:
playlist = self._extend_playlists_tracks(playlist, limit_tracks)
if fetch_all:
for additional_data in self._extend_api_data(
playlist["relationships"]["tracks"],
limit_tracks,
):
playlist["relationships"]["tracks"]["data"].extend(additional_data)
return playlist
def _extend_playlists_tracks(
def _extend_api_data(
self,
playlist: dict,
limit_tracks: int,
) -> dict:
playlist_next_uri = playlist["relationships"]["tracks"].get("next")
while playlist_next_uri:
playlist_next = self._get_playlist_next(playlist_next_uri, limit_tracks)
playlist["relationships"]["tracks"]["data"].extend(playlist_next["data"])
playlist_next_uri = playlist_next.get("next")
api_response: dict,
limit: int,
) -> typing.Generator[list[dict], None, None]:
next_uri = api_response.get("next")
while next_uri:
playlist_next = self._get_next_uri_response(next_uri, limit)
yield playlist_next["data"]
next_uri = playlist_next.get("next")
time.sleep(self.WAIT_TIME)
return playlist
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
response = self.session.get(
self.AMP_API_URL + playlist_next_uri,
self.AMP_API_URL + next_uri,
params={
"limit[tracks]": limit_tracks,
"limit": limit,
},
)
self._check_amp_api_response(response)
+9 -4
View File
@@ -35,7 +35,7 @@ def get_param_string(param: click.Parameter) -> str:
return param.default
def write_default_config_file(ctx: click.Context) -> None:
def write_default_config_file(ctx: click.Context):
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
config_file = {
param.name: get_param_string(param)
@@ -95,7 +95,7 @@ def load_config_file(
"--read-urls-as-txt",
"-r",
is_flag=True,
help="Interpret URLs as paths to text files containing URLs.",
help="Interpret URLs as paths to text files containing URLs separated by newlines",
)
@click.option(
"--synced-lyrics-only",
@@ -440,10 +440,15 @@ def main(
)
error_count = 0
if read_urls_as_txt:
urls = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
_urls = []
for url in urls:
if Path(url).exists():
_urls.extend(Path(url).read_text().splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
try:
logger.info(f'({url_progress}) Checking "{url}"')
url_info = downloader.get_url_info(url)
download_queue = downloader.get_download_queue(url_info)
except Exception as e:
@@ -496,8 +501,8 @@ def main(
f'({queue_progress}) Song already exists at "{final_path}", skipping'
)
else:
logger.debug("Getting stream info")
if codec_song in LEGACY_CODECS:
logger.debug("Getting stream info")
stream_info = downloader_song_legacy.get_stream_info(
webplayback
)
+2 -2
View File
@@ -197,8 +197,8 @@ SONG_CODEC_REGEX_MAP = {
}
MUSIC_VIDEO_CODEC_MAP = {
MusicVideoCodec.H264_BEST: "avc1",
MusicVideoCodec.H265_BEST: "hvc1",
MusicVideoCodec.H264: "avc1",
MusicVideoCodec.H265: "hvc1",
}
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
+93 -2
View File
@@ -9,6 +9,8 @@ from pathlib import Path
import ciso8601
import requests
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.mp4 import MP4, MP4Cover
from pywidevine import PSSH, Cdm, Device
from yt_dlp import YoutubeDL
@@ -112,7 +114,7 @@ class Downloader:
def get_url_info(self, url: str) -> UrlInfo:
url_info = UrlInfo()
url_regex_result = re.search(
r"/([a-z]{2})/(album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
url,
)
url_info.storefront = url_regex_result.group(1)
@@ -131,7 +133,10 @@ class Downloader:
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
download_queue = []
if url_type == "song":
if url_type == "artist":
artist = self.apple_music_api.get_artist(id)
download_queue.extend(self.get_download_queue_from_artist(artist))
elif url_type == "song":
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
elif url_type == "album":
album = self.apple_music_api.get_album(id)
@@ -156,6 +161,92 @@ class Downloader:
raise Exception(f"Invalid url type: {url_type}")
return download_queue
def get_download_queue_from_artist(self, artist: dict) -> list[DownloadQueueItem]:
media_type = inquirer.select(
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
choices=[
Choice(name="Albums", value="albums"),
Choice(
name="Music Videos",
value="music-videos",
),
],
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute()
if media_type == "albums":
return self.select_albums_from_artist(
artist["relationships"]["albums"]["data"]
)
elif media_type == "music-videos":
return self.select_music_videos_from_artist(
artist["relationships"]["music-videos"]["data"]
)
def select_albums_from_artist(
self,
albums: list[dict],
) -> list[DownloadQueueItem]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums
]
selected = inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
download_queue = []
for album in selected:
download_queue.extend(
DownloadQueueItem(track)
for track in self.apple_music_api.get_album(album["id"])[
"relationships"
]["tracks"]["data"]
)
return download_queue
def select_music_videos_from_artist(
self,
music_videos: list[dict],
) -> list[DownloadQueueItem]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos
]
selected = inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute()
return [DownloadQueueItem(music_video) for music_video in selected]
@staticmethod
def millis_to_min_sec(millis):
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02d}:{seconds:02d}"
def sanitize_date(self, date: str):
datetime_obj = ciso8601.parse_datetime(date)
return datetime_obj.strftime(self.template_date)
+34 -40
View File
@@ -4,10 +4,9 @@ import re
import subprocess
import urllib.parse
from pathlib import Path
import click
import m3u8
from tabulate import tabulate
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .constants import MUSIC_VIDEO_CODEC_MAP
from .downloader import Downloader
@@ -21,7 +20,7 @@ class DownloaderMusicVideo:
def __init__(
self,
downloader: Downloader,
codec: MusicVideoCodec = MusicVideoCodec.H264_BEST,
codec: MusicVideoCodec = MusicVideoCodec.H264,
):
self.downloader = downloader
self.codec = codec
@@ -54,7 +53,7 @@ class DownloaderMusicVideo:
playlist
for playlist in playlists
if playlist["stream_info"]["codecs"].startswith(
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264_BEST]
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
)
]
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
@@ -64,24 +63,24 @@ class DownloaderMusicVideo:
self,
playlists: list[dict],
) -> dict:
table = [
[
i,
playlist["stream_info"]["codecs"],
playlist["stream_info"]["resolution"],
playlist["stream_info"]["bandwidth"],
]
for i, playlist in enumerate(playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt("Choose a video codec", type=click.IntRange(1, len(table)))
- 1
choices = [
Choice(
name=" | ".join(
[
playlist["stream_info"]["codecs"][:4],
playlist["stream_info"]["resolution"],
str(playlist["stream_info"]["bandwidth"]),
]
),
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]
for playlist in playlists
]
selected = inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute()
return selected
def get_playlist_audio(
self,
@@ -101,24 +100,18 @@ class DownloaderMusicVideo:
self,
playlists: list[dict],
) -> dict:
table = [
[
i,
playlist["group_id"],
]
for i, playlist in enumerate(playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt(
"Choose an audio codec", type=click.IntRange(1, len(table))
)
- 1
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return playlists[choice]
for playlist in playlists
]
selected = inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(self, m3u8_data: dict):
return next(
@@ -237,7 +230,7 @@ class DownloaderMusicVideo:
decrypted_path_audio: Path,
decrypted_path_video: Path,
fixed_path: Path,
) -> None:
):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -248,6 +241,7 @@ class DownloaderMusicVideo:
decrypted_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
fixed_path,
],
+14 -15
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from pathlib import Path
import click
from tabulate import tabulate
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .downloader import Downloader
from .enums import PostQuality
@@ -38,21 +40,18 @@ class DownloaderPost:
def get_stream_url_from_user(self, metadata: dict) -> str:
qualities = list(metadata["attributes"]["assetTokens"].keys())
table = [
[index, quality]
for index, quality in enumerate(
qualities,
start=1,
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
print(tabulate(table))
try:
choice = (
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return metadata["attributes"]["assetTokens"][qualities[choice]]
selected = inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute()
return metadata["attributes"]["assetTokens"][selected]
def get_stream_url(self, metadata: dict) -> str:
if self.quality == PostQuality.BEST:
+23 -20
View File
@@ -9,9 +9,9 @@ from pathlib import Path
from xml.dom import minidom
from xml.etree import ElementTree
import click
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
import m3u8
from tabulate import tabulate
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
from .downloader import Downloader
@@ -72,18 +72,18 @@ class DownloaderSong:
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
table = [
[i, playlist["stream_info"]["audio"]]
for i, playlist in enumerate(m3u8_master_playlists, 1)
]
print(tabulate(table))
try:
choice = (
click.prompt("Choose a codec", type=click.IntRange(1, len(table))) - 1
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
except click.exceptions.Abort:
raise KeyboardInterrupt()
return m3u8_master_playlists[choice]
for playlist in m3u8_master_playlists
]
selected = inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute()
return selected
def get_pssh(
self,
@@ -178,22 +178,24 @@ class DownloaderSong:
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
def get_lyrics(self, track_metadata: dict) -> Lyrics:
lyrics = Lyrics()
if not track_metadata["attributes"]["hasLyrics"]:
return Lyrics()
return lyrics
elif track_metadata.get("relationships") is None:
track_metadata = self.downloader.apple_music_api.get_song(
track_metadata["id"]
)
if track_metadata["relationships"]["lyrics"]["data"] and track_metadata[
"relationships"
]["lyrics"]["data"][0].get("attributes"):
return self._get_lyrics(
if (
track_metadata["relationships"].get("lyrics")
and track_metadata["relationships"]["lyrics"].get("data")
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
):
lyrics = self._get_lyrics(
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
"ttml"
]
)
else:
return Lyrics()
return lyrics
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
lyrics = Lyrics("", "")
@@ -320,6 +322,7 @@ class DownloaderSong:
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
+4 -1
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import subprocess
from pathlib import Path
@@ -65,7 +67,7 @@ class DownloaderSongLegacy(DownloaderSong):
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
subprocess.run(
[
self.downloader.mp4box_path_full,
@@ -74,6 +76,7 @@ class DownloaderSongLegacy(DownloaderSong):
decrypted_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
remuxed_path,
],
+2 -2
View File
@@ -33,8 +33,8 @@ class SyncedLyricsFormat(Enum):
class MusicVideoCodec(Enum):
H264_BEST = "h264-best"
H265_BEST = "h265-best"
H264 = "h264"
H265 = "h265"
ASK = "ask"
+2 -2
View File
@@ -1,13 +1,13 @@
[project]
name = "gamdl"
description = "A Python script to download Apple Music songs/music videos/albums/playlists/post videos."
description = "A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts."
requires-python = ">=3.8"
authors = [{ name = "glomatico" }]
dependencies = [
"ciso8601",
"click",
"inquirerpy",
"m3u8",
"tabulate",
"pywidevine",
"pyyaml",
"yt-dlp",
+1 -1
View File
@@ -1,7 +1,7 @@
ciso8601
click
inquirerpy
m3u8
tabulate
pywidevine
pyyaml
yt-dlp