64 | Your best friend 65 |
66 | 67 |76 | Few words... 77 |
78 | 79 |92 | Only Smile 93 | Russia 94 |
95 | 96 | 97 |107 | 108 | Contact Only Smile 109 | 110 |
111 | 112 | 113 |114 | 115 | Streaming and 116 | 119 | 117 | Download help 118 |
120 |Official | 10 ||
Bandcamp 13 | | 14 ||
Deezer | 19 ||
Spotify | 31 ||
YouTube | 39 ||
Official merchandise | 42 ||
All In Merchandise | 46 ||
Amazon | 51 ||
Direct Merch | 55 ||
iTunes | 59 ||
Labels | 62 ||
Nuclear Blast | 66 |
Name | 7 |Type | 8 |Year | 9 |Reviews | 10 |
---|---|---|---|
Ghost Bath 15 | | 16 |EP | 17 |2013 | 18 |19 | 2 (29%) 20 | | 21 |
Funeral | 24 |Full-length | 25 |2014 | 26 |27 | 5 (66%) 28 | | 29 |
Burial | 32 |Single | 33 |2014 | 34 |35 | 36 | | 37 |
Moonlover | 40 |Full-length | 41 |2015 | 42 |43 | 9 (54%) 44 | | 45 |
Starmourner 48 | | 49 |Full-length | 50 |2017 | 51 |52 | 3 (37%) 53 | | 54 |
Self 57 | Loather | 58 |Full-length | 59 |2021 | 60 |61 | 2 (68%) 62 | | 63 |
music_object.indexing_values
.
74 | If any returned value exists in Collection._attribute_to_object_map
,
75 | the music_object exists
76 | """)
77 |
78 | subgraph merge["Merging"]
79 |
80 | _merge("""merges the passed in object in the already
81 | existing whith existing.merge(new)
""")
82 |
83 | _map("""In case a new source or something simmilar
84 | has been addet, it maps the existing object again.
85 | """)
86 |
87 | _merge --> _map
88 |
89 | end
90 |
91 | subgraph add["Adding"]
92 |
93 | __map("""map the values from music_object.indexing_values
94 | to Collection._attribute_to_object_map
by writing
95 | those values in the map as keys, and the class I wanna add as values.
96 | """)
97 |
98 | _add("""add the new music object to _data
""")
99 |
100 | __map --> _add
101 |
102 | end
103 |
104 | exist-->|"if it doesn't exist"|add --> return
105 | exist-->|"if already exists"|merge --> return
106 | ```
107 |
108 | This is Implemented in [music_kraken.objects.Collection.append()](documentation/objects.md#collection). The merging which is mentioned in the flowchart is explained in the documentation of [DatabaseObject.merge()](documentation/objects.md#databaseobjectmerge).
109 |
110 | The indexing values are defined in the superclass [DatabaseObject](documentation/objects.md#databaseobject) and get implemented for each Object seperately. I will just give as example its implementation for the `Song` class:
111 |
112 | ```python
113 | @property
114 | def indexing_values(self) -> List[Tuple[str, object]]:
115 | return [
116 | ('id', self.id),
117 | ('title', self.unified_title),
118 | ('barcode', self.barcode),
119 | *[('url', source.url) for source in self.source_collection]
120 | ]
121 | ```
122 |
123 | ## Song
124 |
125 | This object inherits from [DatabaseObject](#databaseobject) and implements all its interfaces.
126 |
127 | It has handful attributes, where half of em are self-explanatory, like `title` or `genre`. The ones like `isrc` are only relevant to you, if you know what it is, so I won't elaborate on it.
128 |
129 | Interesting is the `date`. It uses a custom class. More on that [here](#music_krakenid3timestamp).
130 |
131 | ## ID3Timestamp
132 |
133 | For multiple Reasons I don't use the default `datetime.datetime` class.
134 |
135 | The most important reason is, that you need to pass in at least year, month and day. For every other values there are default values, that are indistinguishable from values that are directly passed in. But I need optional values. The ID3 standart allows default values. Additionally `datetime.datetime` is immutable, thus I can't inherint all the methods. Sorry.
136 |
137 | Anyway you can create those custom objects easily.
138 |
139 | ```python
140 | from music_kraken import ID3Timestamp
141 |
142 | # returns an instance of ID3Timestamp with the current time
143 | ID3Timestamp.now()
144 |
145 | # returns an instance of ID3Timestamp with the given values
146 | # all values are optional if unknown
147 | ID3Timestamp(year=1986, month=3, day=1, hour=12, minute=30, second=6)
148 | ```
149 |
--------------------------------------------------------------------------------
/documentation/program_structure.md:
--------------------------------------------------------------------------------
1 | # Downloading
2 |
3 | ## Query
4 |
5 | - parsing query into music objects
6 |
7 | # Pages
8 |
9 | ## from music objects to query
10 |
11 | ```
12 | song: artist1, artist2
13 |
14 | 1. artist1 - song
15 | 2. artist2 - song
16 | ```
17 |
18 | ```
19 | artist: song1, song2
20 |
21 | 1. artist
22 | ```
23 |
--------------------------------------------------------------------------------
/documentation/shell.md:
--------------------------------------------------------------------------------
1 | # Shell
2 |
3 | ## Searching
4 |
5 | ```mkshell
6 | > s: {querry or url}
7 |
8 | # examples
9 | > s: https://musify.club/release/some-random-release-183028492
10 | > s: r: #a an Artist #r some random Release
11 | ```
12 |
13 | Searches for an url, or a query
14 |
15 | ### Query Syntax
16 |
17 | ```
18 | #a {artist} #r {release} #t {track}
19 | ```
20 |
21 | You can escape stuff like `#` doing this: `\#`
22 |
23 | ## Downloading
24 |
25 | To download something, you either need a direct link, or you need to have already searched for options
26 |
27 | ```mkshell
28 | > d: {option ids or direct url}
29 |
30 | # examples
31 | > d: 0, 3, 4
32 | > d: 1
33 | > d: https://musify.club/release/some-random-release-183028492
34 | ```
35 |
36 | ## Results
37 |
38 | If options are printed in **bold** they can be downloaded. Else they may or maybe can't be downloaded
39 |
40 | ## Misc
41 |
42 | ### Exit
43 |
44 | ```mkshell
45 | > q
46 | > quit
47 | > exit
48 | > abort
49 | ```
50 |
51 | ### Current Options
52 |
53 | ```mkshell
54 | > .
55 | ```
56 |
57 | ### Previous Options
58 |
59 | ```
60 | > ..
61 | ```
62 |
--------------------------------------------------------------------------------
/music_kraken/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import gc
3 | import sys
4 | from pathlib import Path
5 |
6 | from rich.logging import RichHandler
7 | from rich.console import Console
8 |
9 | from .utils.shared import DEBUG, DEBUG_LOGGING
10 | from .utils.config import logging_settings, main_settings, read_config
11 |
12 | read_config()
13 |
14 | console: Console = Console()
15 | def init_logging():
16 | log_file = main_settings['log_file']
17 |
18 | if log_file.is_file():
19 | last_log_file = Path(log_file.parent, "prev." + log_file.name)
20 |
21 | with log_file.open("r", encoding="utf-8") as current_file:
22 | with last_log_file.open("w", encoding="utf-8") as last_file:
23 | last_file.write(current_file.read())
24 |
25 | rich_handler = RichHandler(rich_tracebacks=True, console=console)
26 | rich_handler.setLevel(logging_settings['log_level'] if not DEBUG_LOGGING else logging.DEBUG)
27 |
28 | file_handler = logging.FileHandler(log_file)
29 | file_handler.setLevel(logging.DEBUG)
30 |
31 | # configure logger default
32 | logging.basicConfig(
33 | level=logging.DEBUG,
34 | format=logging_settings['logging_format'],
35 | datefmt="%Y-%m-%d %H:%M:%S",
36 | handlers=[
37 | file_handler,
38 | rich_handler,
39 | ]
40 | )
41 |
42 | init_logging()
43 |
44 | from . import cli
45 |
46 | if DEBUG:
47 | sys.setrecursionlimit(300)
48 |
49 |
50 | if main_settings['modify_gc']:
51 | """
52 | At the start I modify the garbage collector to run a bit fewer times.
53 | This should increase speed:
54 | https://mkennedy.codes/posts/python-gc-settings-change-this-and-make-your-app-go-20pc-faster/
55 | """
56 | # Clean up what might be garbage so far.
57 | gc.collect(2)
58 |
59 | allocs, gen1, gen2 = gc.get_threshold()
60 | allocs = 50_000 # Start the GC sequence every 50K not 700 allocations.
61 | gen1 = gen1 * 2
62 | gen2 = gen2 * 2
63 | gc.set_threshold(allocs, gen1, gen2)
64 |
65 |
66 |
--------------------------------------------------------------------------------
/music_kraken/__main__.py:
--------------------------------------------------------------------------------
1 | def cli():
2 | import argparse
3 |
4 | parser = argparse.ArgumentParser(
5 | description="A simple yet powerful cli to download music with music-kraken.",
6 | epilog="This is a cli for the developers, and it is shipped with music-krakens core.\n"
7 | "While it is a nice and solid cli it will lack some features.\n"
8 | "The proper cli and other frontends will be made or already have been made.\n"
9 | "To see all current frontends check the docs at: https://github.com/HeIIow2/music-downloader"
10 | )
11 |
12 | # arguments for debug purposes
13 | parser.add_argument(
14 | '-v', '--verbose',
15 | action="store_true",
16 | help="Sets the logging level to debug."
17 | )
18 |
19 | parser.add_argument(
20 | '-m', '--force-post-process',
21 | action="store_true",
22 | help="If a to downloaded thing is skipped due to being found on disc,\nit will still update the metadata accordingly."
23 | )
24 |
25 | parser.add_argument(
26 | '-t', '--test',
27 | action="store_true",
28 | help="For the sake of testing. Equals: '-vp -g test'"
29 | )
30 |
31 | # general arguments
32 | parser.add_argument(
33 | '-a', '--all',
34 | action="store_true",
35 | help="If set it will download EVERYTHING the music downloader can find.\n"
36 | "For example weird compilations from musify."
37 | )
38 |
39 | parser.add_argument(
40 | '-g', '--genre',
41 | help="Specifies the genre. (Will be overwritten by -t)"
42 | )
43 |
44 | parser.add_argument(
45 | '-u', '--url',
46 | help="Downloads the content of given url."
47 | )
48 |
49 | parser.add_argument(
50 | '--settings',
51 | help="Opens a menu to modify the settings",
52 | action="store_true"
53 | )
54 |
55 | parser.add_argument(
56 | '-s',
57 | '--setting',
58 | help="Modifies a setting directly.",
59 | nargs=2
60 | )
61 |
62 | parser.add_argument(
63 | "--paths",
64 | "-p",
65 | help="Prints an overview over all music-kraken paths.",
66 | action="store_true"
67 | )
68 |
69 | parser.add_argument(
70 | "-r",
71 | help="Resets the config file to the default one.",
72 | action="store_true"
73 | )
74 |
75 | parser.add_argument(
76 | "--frontend",
77 | "-f",
78 | help="Set a good and fast invidious/piped instance from your homecountry, to reduce the latency.",
79 | action="store_true"
80 | )
81 |
82 | parser.add_argument(
83 | "--clear-cache",
84 | help="Deletes the cache.",
85 | action="store_true"
86 | )
87 |
88 | parser.add_argument(
89 | "--clean-cache",
90 | help="Deletes the outdated cache. (all expired cached files, and not indexed files)",
91 | action="store_true"
92 | )
93 |
94 | arguments = parser.parse_args()
95 |
96 | if arguments.verbose or arguments.test:
97 | import logging
98 | print("Setting logging-level to DEBUG")
99 | logging.getLogger().setLevel(logging.DEBUG)
100 |
101 | from . import cli
102 | from .utils.config import read_config
103 | from .utils import shared
104 |
105 | if arguments.r:
106 | import os
107 |
108 | for file in shared.CONFIG_DIRECTORY.iterdir():
109 | if file.is_file():
110 | print(f"Deleting {file}....")
111 | file.unlink()
112 | read_config()
113 |
114 | exit()
115 |
116 | read_config()
117 |
118 | if arguments.setting is not None:
119 | cli.settings(*arguments.setting)
120 |
121 | if arguments.settings:
122 | cli.settings()
123 |
124 | if arguments.paths:
125 | cli.print_paths()
126 |
127 | if arguments.frontend:
128 | cli.set_frontend(silent=False)
129 |
130 | if arguments.clear_cache:
131 | from .cli.options import cache
132 | cache.clear_cache()
133 |
134 | if arguments.clean_cache:
135 | from .cli.options import cache
136 | cache.clean_cache()
137 |
138 | # getting the genre
139 | genre: str = arguments.genre
140 | if arguments.test:
141 | genre = "test"
142 |
143 | cli.download(
144 | genre=genre,
145 | download_all=arguments.all,
146 | direct_download_url=arguments.url,
147 | process_metadata_anyway=True or arguments.test
148 | )
149 |
150 |
151 | if __name__ == "__main__":
152 | cli()
153 |
--------------------------------------------------------------------------------
/music_kraken/audio/__init__.py:
--------------------------------------------------------------------------------
1 | from . import metadata
2 | from . import codec
3 |
4 | AudioMetadata = metadata.AudioMetadata
5 | write_many_metadata = metadata.write_many_metadata
6 | write_metadata = metadata.write_metadata
7 | write_metadata_to_target = metadata.write_metadata_to_target
8 |
9 | correct_codec = codec.correct_codec
10 |
--------------------------------------------------------------------------------
/music_kraken/audio/codec.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import List, Tuple
3 | from tqdm import tqdm
4 | from ffmpeg_progress_yield import FfmpegProgress
5 |
6 | from ..utils.config import main_settings, logging_settings
7 | from ..objects import Target
8 |
9 |
10 | LOGGER = logging_settings["codex_logger"]
11 |
12 |
13 | def correct_codec(target: Target, bitrate_kb: int = main_settings["bitrate"], audio_format: str = main_settings["audio_format"], skip_intervals: List[Tuple[float, float]] = None):
14 | if not target.exists:
15 | LOGGER.warning(f"Target doesn't exist: {target.file_path}")
16 | return
17 |
18 | skip_intervals = skip_intervals or []
19 |
20 | bitrate_b = int(bitrate_kb / 1024)
21 |
22 | output_target = Target(
23 | file_path=Path(str(target.file_path) + "." + audio_format)
24 | )
25 |
26 | # get the select thingie
27 | # https://stackoverflow.com/questions/50594412/cut-multiple-parts-of-a-video-with-ffmpeg
28 | aselect_list: List[str] = []
29 |
30 | start = 0
31 | next_start = 0
32 | for end, next_start in skip_intervals:
33 | aselect_list.append(f"between(t,{start},{end})")
34 | start = next_start
35 | aselect_list.append(f"gte(t,{next_start})")
36 |
37 | select = f"aselect='{'+'.join(aselect_list)}',asetpts=N/SR/TB"
38 |
39 | # build the ffmpeg command
40 | ffmpeg_command = [
41 | str(main_settings["ffmpeg_binary"]),
42 | "-i", str(target.file_path),
43 | "-af", select,
44 | "-b", str(bitrate_b),
45 | str(output_target.file_path)
46 | ]
47 |
48 | # run the ffmpeg command with a progressbar
49 | ff = FfmpegProgress(ffmpeg_command)
50 | with tqdm(total=100, desc=f"processing") as pbar:
51 | for progress in ff.run_command_with_progress():
52 | pbar.update(progress-pbar.n)
53 |
54 | LOGGER.debug(ff.stderr)
55 |
56 | output_target.copy_content(target)
57 | output_target.delete()
58 |
--------------------------------------------------------------------------------
/music_kraken/audio/metadata.py:
--------------------------------------------------------------------------------
1 | import mutagen
2 | from mutagen.id3 import ID3, Frame, APIC, USLT
3 | from pathlib import Path
4 | from typing import List
5 | import logging
6 | from PIL import Image
7 |
8 | from ..utils.config import logging_settings, main_settings
9 | from ..objects import Song, Target, Metadata
10 | from ..objects.metadata import Mapping
11 | from ..connection import Connection
12 |
13 | LOGGER = logging_settings["tagging_logger"]
14 |
15 |
16 | artwork_connection: Connection = Connection()
17 |
18 |
19 | class AudioMetadata:
20 | def __init__(self, file_location: str = None) -> None:
21 | self._file_location = None
22 |
23 | self.frames: ID3 = ID3()
24 |
25 | if file_location is not None:
26 | self.file_location = file_location
27 |
28 | def add_metadata(self, metadata: Metadata):
29 | for value in metadata:
30 | """
31 | https://www.programcreek.com/python/example/84797/mutagen.id3.ID3
32 | """
33 | if value is None:
34 | continue
35 | self.frames.add(value)
36 |
37 | def add_song_metadata(self, song: Song):
38 | self.add_metadata(song.metadata)
39 |
40 | def save(self, file_location: Path = None):
41 | LOGGER.debug(f"saving following frames: {self.frames.pprint()}")
42 |
43 | if file_location is not None:
44 | self.file_location = file_location
45 |
46 | if self.file_location is None:
47 | raise Exception("no file target provided to save the data to")
48 | self.frames.save(self.file_location, v2_version=4)
49 |
50 | def set_file_location(self, file_location: Path):
51 | # try loading the data from the given file. if it doesn't succeed the frame remains empty
52 | try:
53 | self.frames.load(file_location, v2_version=4)
54 | LOGGER.debug(f"loaded following from \"{file_location}\"\n{self.frames.pprint()}")
55 | except mutagen.MutagenError:
56 | LOGGER.warning(f"couldn't find any metadata at: \"{self.file_location}\"")
57 | self._file_location = file_location
58 |
59 | file_location = property(fget=lambda self: self._file_location, fset=set_file_location)
60 |
61 |
62 | def write_metadata_to_target(metadata: Metadata, target: Target, song: Song):
63 | if not target.exists:
64 | LOGGER.warning(f"file {target.file_path} not found")
65 | return
66 |
67 | id3_object = AudioMetadata(file_location=target.file_path)
68 |
69 | LOGGER.info(str(metadata))
70 |
71 | if song.artwork.best_variant is not None:
72 | best_variant = song.artwork.best_variant
73 |
74 | r = artwork_connection.get(
75 | url=best_variant["url"],
76 | name=song.artwork.get_variant_name(best_variant),
77 | )
78 |
79 | temp_target: Target = Target.temp()
80 | with temp_target.open("wb") as f:
81 | f.write(r.content)
82 |
83 | converted_target: Target = Target.temp(name=f"{song.title.replace('/', '_')}")
84 | with Image.open(temp_target.file_path) as img:
85 | # crop the image if it isn't square in the middle with minimum data loss
86 | width, height = img.size
87 | if width != height:
88 | if width > height:
89 | img = img.crop((width // 2 - height // 2, 0, width // 2 + height // 2, height))
90 | else:
91 | img = img.crop((0, height // 2 - width // 2, width, height // 2 + width // 2))
92 |
93 | # resize the image to the preferred resolution
94 | img.thumbnail((main_settings["preferred_artwork_resolution"], main_settings["preferred_artwork_resolution"]))
95 |
96 | # https://stackoverflow.com/a/59476938/16804841
97 | if img.mode != 'RGB':
98 | img = img.convert('RGB')
99 |
100 | img.save(converted_target.file_path, "JPEG")
101 |
102 | # https://stackoverflow.com/questions/70228440/mutagen-how-can-i-correctly-embed-album-art-into-mp3-file-so-that-i-can-see-t
103 | id3_object.frames.delall("APIC")
104 | id3_object.frames.add(
105 | APIC(
106 | encoding=0,
107 | mime="image/jpeg",
108 | type=3,
109 | desc=u"Cover",
110 | data=converted_target.read_bytes(),
111 | )
112 | )
113 | id3_object.frames.delall("USLT")
114 | uslt_val = metadata.get_id3_value(Mapping.UNSYNCED_LYRICS)
115 | id3_object.frames.add(
116 | USLT(encoding=3, lang=u'eng', desc=u'desc', text=uslt_val)
117 | )
118 |
119 | id3_object.add_metadata(metadata)
120 | id3_object.save()
121 |
122 |
123 | def write_metadata(song: Song, ignore_file_not_found: bool = True):
124 | target: Target
125 | for target in song.target:
126 | if not target.exists:
127 | if ignore_file_not_found:
128 | continue
129 | else:
130 | raise ValueError(f"{song.target.file} not found")
131 |
132 | write_metadata_to_target(metadata=song.metadata, target=target, song=song)
133 |
134 |
135 | def write_many_metadata(song_list: List[Song]):
136 | for song in song_list:
137 | write_metadata(song=song, ignore_file_not_found=True)
138 |
--------------------------------------------------------------------------------
/music_kraken/cli/__init__.py:
--------------------------------------------------------------------------------
1 | from .informations import print_paths
2 | from .main_downloader import download
3 | from .options.settings import settings
4 | from .options.frontend import set_frontend
5 |
6 |
--------------------------------------------------------------------------------
/music_kraken/cli/informations/__init__.py:
--------------------------------------------------------------------------------
1 | from .paths import print_paths
--------------------------------------------------------------------------------
/music_kraken/cli/informations/paths.py:
--------------------------------------------------------------------------------
1 | from ..utils import cli_function
2 |
3 | from ...utils.path_manager import LOCATIONS
4 | from ...utils.config import main_settings
5 |
6 |
7 | def all_paths():
8 | return {
9 | "Temp dir": main_settings["temp_directory"],
10 | "Music dir": main_settings["music_directory"],
11 | "Conf dir": LOCATIONS.CONFIG_DIRECTORY,
12 | "Conf file": LOCATIONS.CONFIG_FILE,
13 | "logging file": main_settings["log_file"],
14 | "FFMPEG bin": main_settings["ffmpeg_binary"],
15 | "Cache Dir": main_settings["cache_directory"],
16 | }
17 |
18 |
19 | @cli_function
20 | def print_paths():
21 | for name, path in all_paths().items():
22 | print(f"{name}:\t{path}")
--------------------------------------------------------------------------------
/music_kraken/cli/options/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Kur01234/music-downloader/665bf0e47517071450b6f25dffbb81fe21944ead/music_kraken/cli/options/__init__.py
--------------------------------------------------------------------------------
/music_kraken/cli/options/cache.py:
--------------------------------------------------------------------------------
1 | from logging import getLogger
2 |
3 | from ..utils import cli_function
4 | from ...connection.cache import Cache
5 |
6 |
7 | @cli_function
8 | def clear_cache():
9 | """
10 | Deletes the cache.
11 | :return:
12 | """
13 |
14 | Cache("main", getLogger("cache")).clear()
15 | print("Cleared cache")
16 |
17 |
18 | @cli_function
19 | def clean_cache():
20 | """
21 | Deletes the outdated cache. (all expired cached files, and not indexed files)
22 | :return:
23 | """
24 |
25 | Cache("main", getLogger("cache")).clean()
26 | print("Cleaned cache")
27 |
--------------------------------------------------------------------------------
/music_kraken/cli/options/first_config.py:
--------------------------------------------------------------------------------
1 | from .frontend import set_frontend
2 |
3 |
4 | def initial_config():
5 | code = set_frontend(no_cli=True)
6 | return code
7 |
--------------------------------------------------------------------------------
/music_kraken/cli/options/frontend.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, List
2 | from dataclasses import dataclass
3 | from collections import defaultdict
4 | from urllib.parse import urlparse
5 |
6 | from ..utils import cli_function
7 |
8 | from ...objects import Country
9 | from ...utils import config, write_config
10 | from ...utils.config import youtube_settings
11 | from ...connection import Connection
12 |
13 |
14 | @dataclass
15 | class Instance:
16 | """
17 | Attributes which influence the quality of an instance:
18 |
19 | - users
20 | """
21 | name: str
22 | uri: str
23 | regions: List[Country]
24 | users: int = 0
25 |
26 | def __str__(self) -> str:
27 | return f"{self.name} with {self.users} users."
28 |
29 |
30 | class FrontendInstance:
31 | SETTING_NAME = "placeholder"
32 |
33 | def __init__(self) -> None:
34 | self.region_instances: Dict[Country, List[Instance]] = defaultdict(list)
35 | self.all_instances: List[Instance] = []
36 |
37 | def add_instance(self, instance: Instance):
38 | self.all_instances.append(instance)
39 |
40 | youtube_lists = youtube_settings["youtube_url"]
41 | existing_netlocs = set(tuple(url.netloc for url in youtube_lists))
42 |
43 | parsed_instance = urlparse(instance.uri)
44 | instance_netloc = parsed_instance.netloc
45 |
46 | if instance_netloc not in existing_netlocs:
47 | youtube_lists.append(parsed_instance)
48 | youtube_settings.__setitem__("youtube_url", youtube_lists, is_parsed=True)
49 |
50 | for region in instance.regions:
51 | self.region_instances[region].append(instance)
52 |
53 | def fetch(self, silent: bool = False):
54 | if not silent:
55 | print(f"Downloading {type(self).__name__} instances...")
56 |
57 | def set_instance(self, instance: Instance):
58 | youtube_settings.__setitem__(self.SETTING_NAME, instance.uri)
59 |
60 | def _choose_country(self) -> List[Instance]:
61 | print("Input the country code, an example would be \"US\"")
62 | print('\n'.join(f'{region.name} ({region.alpha_2})' for region in self.region_instances))
63 | print()
64 |
65 |
66 | available_instances = set(i.alpha_2 for i in self.region_instances)
67 |
68 | chosen_region = ""
69 |
70 | while chosen_region not in available_instances:
71 | chosen_region = input("nearest country: ").strip().upper()
72 |
73 | return self.region_instances[Country.by_alpha_2(chosen_region)]
74 |
75 | def choose(self, silent: bool = False):
76 | instances = self.all_instances if silent else self._choose_country()
77 | instances.sort(key=lambda x: x.users, reverse=True)
78 |
79 | if silent:
80 | self.set_instance(instances[0])
81 | return
82 |
83 | # output the options
84 | print("Choose your instance (input needs to be a digit):")
85 | for i, instance in enumerate(instances):
86 | print(f"{i}) {instance}")
87 |
88 | print()
89 |
90 | # ask for index
91 | index = ""
92 | while not index.isdigit() or int(index) >= len(instances):
93 | index = input("> ").strip()
94 |
95 | instance = instances[int(index)]
96 | print()
97 | print(f"Setting the instance to {instance}")
98 |
99 | self.set_instance(instance)
100 |
101 |
102 | class Invidious(FrontendInstance):
103 | SETTING_NAME = "invidious_instance"
104 |
105 | def __init__(self) -> None:
106 | self.connection = Connection(host="https://api.invidious.io/")
107 | self.endpoint = "https://api.invidious.io/instances.json"
108 |
109 | super().__init__()
110 |
111 |
112 | def _process_instance(self, all_instance_data: dict):
113 | instance_data = all_instance_data[1]
114 | stats = instance_data["stats"]
115 |
116 | if not instance_data["api"]:
117 | return
118 | if instance_data["type"] != "https":
119 | return
120 |
121 | region = instance_data["region"]
122 |
123 | instance = Instance(
124 | name=all_instance_data[0],
125 | uri=instance_data["uri"],
126 | regions=[Country.by_alpha_2(region)],
127 | users=stats["usage"]["users"]["total"]
128 | )
129 |
130 | self.add_instance(instance)
131 |
132 | def fetch(self, silent: bool):
133 | r = self.connection.get(self.endpoint)
134 | if r is None:
135 | return
136 |
137 | for instance in r.json():
138 | self._process_instance(all_instance_data=instance)
139 |
140 |
141 | class Piped(FrontendInstance):
142 | SETTING_NAME = "piped_instance"
143 |
144 | def __init__(self) -> None:
145 | self.connection = Connection(host="https://raw.githubusercontent.com")
146 |
147 | super().__init__()
148 |
149 | def process_instance(self, instance_data: str):
150 | cells = instance_data.split(" | ")
151 |
152 | instance = Instance(
153 | name=cells[0].strip(),
154 | uri=cells[1].strip(),
155 | regions=[Country.by_emoji(flag) for flag in cells[2].split(", ")]
156 | )
157 |
158 | self.add_instance(instance)
159 |
160 | def fetch(self, silent: bool = False):
161 | r = self.connection.get("https://raw.githubusercontent.com/wiki/TeamPiped/Piped-Frontend/Instances.md")
162 | if r is None:
163 | return
164 |
165 | process = False
166 |
167 | for line in r.content.decode("utf-8").split("\n"):
168 | line = line.strip()
169 |
170 | if line != "" and process:
171 | self.process_instance(line)
172 |
173 | if line.startswith("---"):
174 | process = True
175 |
176 |
177 | class FrontendSelection:
178 | def __init__(self):
179 | self.invidious = Invidious()
180 | self.piped = Piped()
181 |
182 | def choose(self, silent: bool = False):
183 | self.invidious.fetch(silent)
184 | self.invidious.choose(silent)
185 |
186 | self.piped.fetch(silent)
187 | self.piped.choose(silent)
188 |
189 |
190 | @cli_function
191 | def set_frontend(silent: bool = False):
192 | shell = FrontendSelection()
193 | shell.choose(silent=silent)
194 |
195 | return 0
196 |
--------------------------------------------------------------------------------
/music_kraken/cli/options/settings.py:
--------------------------------------------------------------------------------
1 | from ..utils import cli_function
2 |
3 | from ...utils.config import config, write_config
4 | from ...utils import exception
5 |
6 |
7 | def modify_setting(_name: str, _value: str, invalid_ok: bool = True) -> bool:
8 | try:
9 | config.set_name_to_value(_name, _value)
10 | except exception.config.SettingException as e:
11 | if invalid_ok:
12 | print(e)
13 | return False
14 | else:
15 | raise e
16 |
17 | write_config()
18 | return True
19 |
20 |
21 | def print_settings():
22 | for i, attribute in enumerate(config):
23 | print(f"{i:0>2}: {attribute.name}={attribute.value}")
24 |
25 |
26 | def modify_setting_by_index(index: int) -> bool:
27 | attribute = list(config)[index]
28 |
29 | print()
30 | print(attribute)
31 |
32 | input__ = input(f"{attribute.name}=")
33 | if not modify_setting(attribute.name, input__.strip()):
34 | return modify_setting_by_index(index)
35 |
36 | return True
37 |
38 |
39 | def modify_setting_by_index(index: int) -> bool:
40 | attribute = list(config)[index]
41 |
42 | print()
43 | print(attribute)
44 |
45 | input__ = input(f"{attribute.name}=")
46 | if not modify_setting(attribute.name, input__.strip()):
47 | return modify_setting_by_index(index)
48 |
49 | return True
50 |
51 |
52 | @cli_function
53 | def settings(
54 | name: str = None,
55 | value: str = None,
56 | ):
57 | if name is not None and value is not None:
58 | modify_setting(name, value, invalid_ok=True)
59 | return
60 |
61 | while True:
62 | print_settings()
63 |
64 | input_ = input("Id of setting to modify: ")
65 | print()
66 | if input_.isdigit() and int(input_) < len(config):
67 | if modify_setting_by_index(int(input_)):
68 | return
69 | else:
70 | print("Please input a valid ID.")
71 | print()
--------------------------------------------------------------------------------
/music_kraken/cli/utils.py:
--------------------------------------------------------------------------------
1 | from ..utils.shared import get_random_message
2 |
3 |
4 | def cli_function(function):
5 | def wrapper(*args, **kwargs):
6 | silent = kwargs.get("no_cli", False)
7 | if "no_cli" in kwargs:
8 | del kwargs["no_cli"]
9 |
10 | if silent:
11 | return function(*args, **kwargs)
12 | return
13 |
14 | code = 0
15 |
16 | print_cute_message()
17 | print()
18 | try:
19 | code = function(*args, **kwargs)
20 | except KeyboardInterrupt:
21 | print("\n\nRaise an issue if I fucked up:\nhttps://github.com/HeIIow2/music-downloader/issues")
22 |
23 | finally:
24 | print()
25 | print_cute_message()
26 | print("See you soon! :3")
27 |
28 | exit()
29 |
30 | return wrapper
31 |
32 |
33 | def print_cute_message():
34 | message = get_random_message()
35 | try:
36 | print(message)
37 | except UnicodeEncodeError:
38 | message = str(c for c in message if 0 < ord(c) < 127)
39 | print(message)
40 |
41 |
42 |
--------------------------------------------------------------------------------
/music_kraken/connection/__init__.py:
--------------------------------------------------------------------------------
1 | from .connection import Connection
2 |
--------------------------------------------------------------------------------
/music_kraken/connection/cache.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 | from dataclasses import dataclass, field
4 | from datetime import datetime, timedelta
5 | from typing import List, Optional
6 | from functools import lru_cache
7 | import logging
8 |
9 | from ..utils import output, BColors
10 | from ..utils.config import main_settings
11 | from ..utils.string_processing import fit_to_file_system
12 |
13 |
14 | @dataclass
15 | class CacheAttribute:
16 | module: str
17 | name: str
18 |
19 | created: datetime
20 | expires: datetime
21 |
22 | additional_info: dict = field(default_factory=dict)
23 |
24 | @property
25 | def id(self):
26 | return f"{self.module}_{self.name}"
27 |
28 | @property
29 | def is_valid(self):
30 | if isinstance(self.expires, str):
31 | self.expires = datetime.fromisoformat(self.expires)
32 | return datetime.now() < self.expires
33 |
34 | def __eq__(self, other):
35 | return self.__dict__ == other.__dict__
36 |
37 |
38 | @dataclass
39 | class CacheResult:
40 | content: bytes
41 | attribute: CacheAttribute
42 |
43 |
44 | class Cache:
45 | def __init__(self, module: str, logger: logging.Logger):
46 | self.module = module
47 | self.logger: logging.Logger = logger
48 |
49 | self._dir = main_settings["cache_directory"]
50 | self.index = Path(self._dir, "index.json")
51 |
52 | if not self.index.is_file():
53 | with self.index.open("w") as i:
54 | i.write(json.dumps([]))
55 |
56 | self.cached_attributes: List[CacheAttribute] = []
57 | self._id_to_attribute = {}
58 |
59 | self._time_fields = {"created", "expires"}
60 | with self.index.open("r") as i:
61 | try:
62 | for c in json.loads(i.read()):
63 | for key in self._time_fields:
64 | c[key] = datetime.fromisoformat(c[key])
65 |
66 | ca = CacheAttribute(**c)
67 | self.cached_attributes.append(ca)
68 | self._id_to_attribute[ca.id] = ca
69 | except json.JSONDecodeError:
70 | pass
71 |
72 | @lru_cache()
73 | def _init_module(self, module: str) -> Path:
74 | """
75 | :param module:
76 | :return: the module path
77 | """
78 | r = Path(self._dir, module)
79 | r.mkdir(exist_ok=True, parents=True)
80 | return r
81 |
82 | def _write_index(self, indent: int = 4):
83 | _json = []
84 | for c in self.cached_attributes:
85 | d = c.__dict__
86 | for key in self._time_fields:
87 | if not isinstance(d[key], str):
88 | d[key] = d[key].isoformat()
89 |
90 | _json.append(d)
91 |
92 | with self.index.open("w") as f:
93 | f.write(json.dumps(_json, indent=indent))
94 |
95 | def _write_attribute(self, cached_attribute: CacheAttribute, write: bool = True) -> bool:
96 | existing_attribute: Optional[CacheAttribute] = self._id_to_attribute.get(cached_attribute.id)
97 | if existing_attribute is not None:
98 | # the attribute exists
99 | if existing_attribute == cached_attribute:
100 | return True
101 |
102 | if existing_attribute.is_valid:
103 | return False
104 |
105 | existing_attribute.__dict__ = cached_attribute.__dict__
106 | else:
107 | self.cached_attributes.append(cached_attribute)
108 | self._id_to_attribute[cached_attribute.id] = cached_attribute
109 |
110 | if write:
111 | self._write_index()
112 |
113 | return True
114 |
115 | def set(self, content: bytes, name: str, expires_in: float = 10, module: str = "", additional_info: dict = None):
116 | """
117 | :param content:
118 | :param module:
119 | :param name:
120 | :param expires_in: the unit is days
121 | :return:
122 | """
123 | if name == "":
124 | return
125 |
126 | additional_info = additional_info or {}
127 | module = self.module if module == "" else module
128 |
129 | module_path = self._init_module(module)
130 |
131 | cache_attribute = CacheAttribute(
132 | module=module,
133 | name=name,
134 | created=datetime.now(),
135 | expires=datetime.now() + timedelta(days=expires_in),
136 | additional_info=additional_info,
137 | )
138 | self._write_attribute(cache_attribute)
139 |
140 | cache_path = fit_to_file_system(Path(module_path, name.replace("/", "_")), hidden_ok=True)
141 | with cache_path.open("wb") as content_file:
142 | self.logger.debug(f"writing cache to {cache_path}")
143 | content_file.write(content)
144 |
145 | def get(self, name: str) -> Optional[CacheResult]:
146 | path = fit_to_file_system(Path(self._dir, self.module, name.replace("/", "_")), hidden_ok=True)
147 |
148 | if not path.is_file():
149 | return None
150 |
151 | # check if it is outdated
152 | if f"{self.module}_{name}" not in self._id_to_attribute:
153 | path.unlink()
154 | return
155 | existing_attribute: CacheAttribute = self._id_to_attribute[f"{self.module}_{name}"]
156 | if not existing_attribute.is_valid:
157 | return
158 |
159 | with path.open("rb") as f:
160 | return CacheResult(content=f.read(), attribute=existing_attribute)
161 |
162 | def clean(self):
163 | keep = set()
164 |
165 | for ca in self.cached_attributes.copy():
166 | if ca.name == "":
167 | continue
168 |
169 | file = fit_to_file_system(Path(self._dir, ca.module, ca.name.replace("/", "_")), hidden_ok=True)
170 |
171 | if not ca.is_valid:
172 | self.logger.debug(f"deleting cache {ca.id}")
173 | file.unlink()
174 | self.cached_attributes.remove(ca)
175 | del self._id_to_attribute[ca.id]
176 |
177 | else:
178 | keep.add(file)
179 |
180 | # iterate through every module (folder)
181 | for module_path in self._dir.iterdir():
182 | if not module_path.is_dir():
183 | continue
184 |
185 | # delete all files not in keep
186 | for path in module_path.iterdir():
187 | if path not in keep:
188 | self.logger.info(f"Deleting cache {path}")
189 | path.unlink()
190 |
191 | # delete all empty directories
192 | for path in module_path.iterdir():
193 | if path.is_dir() and not list(path.iterdir()):
194 | self.logger.debug(f"Deleting cache directory {path}")
195 | path.rmdir()
196 |
197 | self._write_index()
198 |
199 | def clear(self):
200 | """
201 | delete every file in the cache directory
202 | :return:
203 | """
204 |
205 | for path in self._dir.iterdir():
206 | if path.is_dir():
207 | for file in path.iterdir():
208 | output(f"Deleting file {file}", color=BColors.GREY)
209 | file.unlink()
210 | output(f"Deleting folder {path}", color=BColors.HEADER)
211 | path.rmdir()
212 | else:
213 | output(f"Deleting folder {path}", color=BColors.HEADER)
214 | path.unlink()
215 |
216 | self.cached_attributes.clear()
217 | self._id_to_attribute.clear()
218 |
219 | self._write_index()
220 |
221 | def __repr__(self):
222 | return f"