Compare commits
46 Commits
fix/reinde
...
da8887b279
| Author | SHA1 | Date | |
|---|---|---|---|
| da8887b279 | |||
|
|
bb32fc7647 | ||
|
|
8c369d79e4 | ||
|
|
b09d6f2691 | ||
| 0e6fe8187a | |||
| 0343c11a62 | |||
| 9769cf4033 | |||
| 55024bd987 | |||
| d85498869d | |||
| c3350b016d | |||
| 788103a68e | |||
| 5179c64161 | |||
| 04405f88eb | |||
| acd183c90e | |||
| 7186f06ce6 | |||
| 6e354af0d1 | |||
| 155f239c8a | |||
| 36db651dfa | |||
| 8426f6e2ea | |||
| 75d0a83d14 | |||
|
|
2af577c0cd | ||
|
|
3780f05e58 | ||
|
|
a0305a7a6e | ||
| 949583225a | |||
| 4e0b005170 | |||
| e3d7ed8837 | |||
| e3e7aea959 | |||
| 9d4e3e8545 | |||
| 9c63e8e55a | |||
| a97f8872c8 | |||
| a5f8057b82 | |||
| e3e547c232 | |||
| 12c0bf6b83 | |||
| ac9a74138c | |||
| 960d3b74ac | |||
| 585e8c9671 | |||
| 4f9261505e | |||
| 08b9492455 | |||
| 9d0dcb412b | |||
| 709c5ebaa8 | |||
| 17c26c5140 | |||
| 0a589d9c64 | |||
| 8abb89ea48 | |||
| 3951394ede | |||
| 73f26e121c | |||
| 3be6c71dcd |
22
.vscode/launch.json
vendored
Normal file
22
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: Download script",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "development/actual_donwload.py",
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -19,13 +19,19 @@
|
||||
"albumsort",
|
||||
"APIC",
|
||||
"Bandcamp",
|
||||
"bitrate",
|
||||
"DEEZER",
|
||||
"dotenv",
|
||||
"encyclopaedia",
|
||||
"ENDC",
|
||||
"Gitea",
|
||||
"isrc",
|
||||
"levenshtein",
|
||||
"metallum",
|
||||
"MUSICBRAINZ",
|
||||
"musify",
|
||||
"OKBLUE",
|
||||
"OKGREEN",
|
||||
"pathvalidate",
|
||||
"Referer",
|
||||
"sponsorblock",
|
||||
|
||||
@@ -11,7 +11,6 @@ steps:
|
||||
build-stable:
|
||||
image: python
|
||||
commands:
|
||||
- sed -i 's/name = "music-kraken"/name = "music-kraken-stable"/' pyproject.toml
|
||||
- python -m pip install -r requirements-dev.txt
|
||||
- python3 -m build
|
||||
environment:
|
||||
|
||||
228
README.md
228
README.md
@@ -2,61 +2,43 @@
|
||||
|
||||
[](https://ci.elara.ws/repos/59)
|
||||
|
||||
<img src="assets/logo.svg" width=300 alt="music kraken logo"/>
|
||||
<img src="https://gitea.elara.ws/music-kraken/music-kraken-core/media/branch/experimental/assets/logo.svg" width=300 alt="music kraken logo"/>
|
||||
|
||||
- [Music Kraken](#music-kraken)
|
||||
- [Installation](#installation)
|
||||
- [From source](#from-source)
|
||||
- [Notes for WSL](#notes-for-wsl)
|
||||
- [Quick-Guide](#quick-guide)
|
||||
- [Query](#query)
|
||||
- [CONTRIBUTE](#contribute)
|
||||
- [Matrix Space](#matrix-space)
|
||||
- [TODO till the next release](#todo-till-the-next-release)
|
||||
- [Programming Interface / Use as Library](#programming-interface--use-as-library)
|
||||
- [Quick Overview](#quick-overview)
|
||||
- [Data Model](#data-model)
|
||||
- [Data Objects](#data-objects)
|
||||
- [Creation](#creation)
|
||||
- [Installation](#installation)
|
||||
- [Quick-Guide](#quick-guide)
|
||||
- [How to search properly](#query)
|
||||
- [Matrix Space](#matrix-space)
|
||||
|
||||
If you want to use this a library or contribute, check out [the wiki](https://gitea.elara.ws/music-kraken/music-kraken-core/wiki) for more information.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
You can find and get this project from either [PyPI](https://pypi.org/project/music-kraken/) as a Python-Package,
|
||||
or simply the source code from [GitHub](https://github.com/HeIIow2/music-downloader). Note that even though
|
||||
everything **SHOULD** work cross-platform, I have only tested it on Ubuntu.
|
||||
If you enjoy this project, feel free to give it a star on GitHub.
|
||||
You can find and get this project from either [PyPI](https://pypi.org/project/music-kraken/) as a Python-Package,
|
||||
or simply the source code from [Gitea](https://gitea.elara.ws/music-kraken/music-kraken-core). **
|
||||
|
||||
> THE PyPI PACKAGE IS OUTDATED
|
||||
**NOTES**
|
||||
|
||||
- Even though everything **SHOULD** work cross-platform, I have only tested it on Ubuntu.
|
||||
- If you enjoy this project, feel free to give it a star on GitHub.
|
||||
|
||||
### From source
|
||||
|
||||
if you use Debian or Ubuntu:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/HeIIow2/music-downloader
|
||||
sudo apt install pandoc
|
||||
|
||||
cd music-downloader/
|
||||
python3 -m pip install -r requirements.txt
|
||||
git clone https://gitea.elara.ws/music-kraken/music-kraken-core.git
|
||||
python3 -m pip install -e music-kraken-core/
|
||||
```
|
||||
|
||||
then you can add to `~/.bashrc`
|
||||
To update the program, if installed like this, go into the `music-kraken-core` directory and run `git pull`.
|
||||
|
||||
```
|
||||
alias music-kraken='cd your/directory/music-downloader/src; python3 -m music_kraken'
|
||||
alias 🥺='sudo'
|
||||
```
|
||||
### Get it running on other Systems
|
||||
|
||||
```sh
|
||||
source ~/.bashrc
|
||||
music-kraken
|
||||
```
|
||||
Here are the collected issues, that are related to running the program on different systems. If you have any issues, feel free to open a new one.
|
||||
|
||||
### Notes for WSL
|
||||
#### Windows + WSL
|
||||
|
||||
If you choose to run it in WSL, make sure ` ~/.local/bin` is added to your `$PATH` [#2][i2]
|
||||
Add ` ~/.local/bin` to your `$PATH`. [#2][i2]
|
||||
|
||||
## Quick-Guide
|
||||
|
||||
@@ -87,10 +69,6 @@ The escape character is as usual `\`.
|
||||
|
||||
---
|
||||
|
||||
## CONTRIBUTE
|
||||
|
||||
I am happy about every pull request. To contribute look [here](contribute.md).
|
||||
|
||||
## Matrix Space
|
||||
|
||||
<img align="right" alt="music-kraken logo" src="assets/element_logo.png" width=100>
|
||||
@@ -99,171 +77,5 @@ I decided against creating a discord server, due to various communities get ofte
|
||||
|
||||
**Click [this invitation](https://matrix.to/#/#music-kraken:matrix.org) _([https://matrix.to/#/#music-kraken:matrix.org](https://matrix.to/#/#music-kraken:matrix.org))_ to join.**
|
||||
|
||||
## TODO till the next release
|
||||
|
||||
> These Points will most likely be in the changelogs.
|
||||
|
||||
- [x] Migrate away from pandoc, to a more lightweight alternative, that can be installed over PiPY.
|
||||
- [ ] Update the Documentation of the internal structure. _(could be pushed back one release)_
|
||||
|
||||
---
|
||||
|
||||
# Programming Interface / Use as Library
|
||||
|
||||
This application is $100\%$ centered around Data. Thus, the most important thing for working with musik kraken is, to understand how I structured the data.
|
||||
|
||||
## Quick Overview
|
||||
|
||||
- explanation of the [Data Model](#data-model)
|
||||
- how to use the [Data Objects](#data-objects)
|
||||
- further Dokumentation of _hopefully_ [most relevant classes](documentation/objects.md)
|
||||
- the [old implementation](documentation/old_implementation.md)
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: Quick Overview (outdated)
|
||||
---
|
||||
sequenceDiagram
|
||||
|
||||
participant pg as Page (eg. YouTube, MB, Musify, ...)
|
||||
participant obj as DataObjects (eg. Song, Artist, ...)
|
||||
participant db as DataBase
|
||||
|
||||
obj ->> db: write
|
||||
db ->> obj: read
|
||||
|
||||
pg -> obj: find a source for any page, for object.
|
||||
obj -> pg: add more detailed data from according page.
|
||||
obj -> pg: if available download audio to target.
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
The Data Structure, that the whole programm is built on looks as follows:
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: Music Data
|
||||
---
|
||||
erDiagram
|
||||
|
||||
|
||||
|
||||
Target {
|
||||
|
||||
}
|
||||
|
||||
Lyrics {
|
||||
|
||||
}
|
||||
|
||||
Song {
|
||||
|
||||
}
|
||||
|
||||
Album {
|
||||
|
||||
}
|
||||
|
||||
Artist {
|
||||
|
||||
}
|
||||
|
||||
Label {
|
||||
|
||||
}
|
||||
|
||||
Source {
|
||||
|
||||
}
|
||||
|
||||
Source }o--|| Song : ""
|
||||
Source }o--|| Lyrics : ""
|
||||
Source }o--|| Album : ""
|
||||
Source }o--|| Artist : ""
|
||||
Source }o--|| Label : ""
|
||||
|
||||
Song }o--o{ Album : AlbumSong
|
||||
Album }o--o{ Artist : ArtistAlbum
|
||||
Song }o--o{ Artist : "ArtistSong (features)"
|
||||
|
||||
Label }o--o{ Album : LabelAlbum
|
||||
Label }o--o{ Artist : LabelSong
|
||||
|
||||
Song ||--o{ Lyrics : ""
|
||||
Song ||--o{ Target : ""
|
||||
```
|
||||
|
||||
Ok now this **WILL** look intimidating, thus I break it down quickly.
|
||||
*That is also the reason I didn't add all Attributes here.*
|
||||
|
||||
The most important Entities are:
|
||||
|
||||
- Song
|
||||
- Album
|
||||
- Artist
|
||||
- Label
|
||||
|
||||
All of them *(and Lyrics)* can have multiple Sources, and every Source can only Point to one of those Element.
|
||||
|
||||
The `Target` Entity represents the location on the hard drive a Song has. One Song can have multiple download Locations.
|
||||
|
||||
The `Lyrics` Entity simply represents the Lyrics of each Song. One Song can have multiple Lyrics, e.g. Translations.
|
||||
|
||||
Here is the simplified Diagramm without only the main Entities.
|
||||
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: simplified Music Data
|
||||
---
|
||||
erDiagram
|
||||
|
||||
Song {
|
||||
|
||||
}
|
||||
|
||||
Album {
|
||||
|
||||
}
|
||||
|
||||
Artist {
|
||||
|
||||
}
|
||||
|
||||
Label {
|
||||
|
||||
}
|
||||
|
||||
Song }o--o{ Album : AlbumSong
|
||||
Album }o--o{ Artist : ArtistAlbum
|
||||
Song }o--o{ Artist : "ArtistSong (features)"
|
||||
|
||||
Label }o--o{ Album : LabelAlbum
|
||||
Label }o--o{ Artist : LabelSong
|
||||
|
||||
```
|
||||
|
||||
Looks way more manageable, doesn't it?
|
||||
|
||||
The reason every relation here is a `n:m` *(many to many)* relation is not, that it makes sense in the aspekt of modeling reality, but to be able to put data from many Sources in the same Data Model.
|
||||
Every Service models Data a bit different, and projecting a one-to-many relationship to a many to many relationship without data loss is easy. The other way around it is basically impossible
|
||||
|
||||
## Data Objects
|
||||
|
||||
> Not 100% accurate yet and *might* change slightly
|
||||
|
||||
### Creation
|
||||
|
||||
```python
|
||||
# needs to be added
|
||||
```
|
||||
|
||||
|
||||
|
||||
If you just want to start implementing, then just use the code example I provided, I don't care.
|
||||
For those who don't want any bugs and use it as intended *(which is recommended, cuz I am only one person so there are defs bugs)* continue reading, and read the whole documentation, which may exist in the future xD
|
||||
|
||||
|
||||
[i10]: https://github.com/HeIIow2/music-downloader/issues/10
|
||||
[i2]: https://github.com/HeIIow2/music-downloader/issues/2
|
||||
|
||||
@@ -6,8 +6,8 @@ logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
if __name__ == "__main__":
|
||||
commands = [
|
||||
"s: #a Psychonaut 4",
|
||||
"d: 0"
|
||||
"s: #a Crystal F",
|
||||
"d: 20",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ def correct_codec(target: Target, bitrate_kb: int = main_settings["bitrate"], au
|
||||
|
||||
# run the ffmpeg command with a progressbar
|
||||
ff = FfmpegProgress(ffmpeg_command)
|
||||
with tqdm(total=100, desc=f"removing {len(interval_list)} segments") as pbar:
|
||||
with tqdm(total=100, desc=f"processing") as pbar:
|
||||
for progress in ff.run_command_with_progress():
|
||||
pbar.update(progress-pbar.n)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import mutagen
|
||||
from mutagen.id3 import ID3, Frame, APIC
|
||||
from mutagen.id3 import ID3, Frame, APIC, USLT
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import logging
|
||||
@@ -7,6 +7,7 @@ from PIL import Image
|
||||
|
||||
from ..utils.config import logging_settings, main_settings
|
||||
from ..objects import Song, Target, Metadata
|
||||
from ..objects.metadata import Mapping
|
||||
from ..connection import Connection
|
||||
|
||||
LOGGER = logging_settings["tagging_logger"]
|
||||
@@ -105,8 +106,11 @@ def write_metadata_to_target(metadata: Metadata, target: Target, song: Song):
|
||||
data=converted_target.read_bytes(),
|
||||
)
|
||||
)
|
||||
|
||||
mutagen_file = mutagen.File(target.file_path)
|
||||
id3_object.frames.delall("USLT")
|
||||
uslt_val = metadata.get_id3_value(Mapping.UNSYNCED_LYRICS)
|
||||
id3_object.frames.add(
|
||||
USLT(encoding=3, lang=u'eng', desc=u'desc', text=uslt_val)
|
||||
)
|
||||
|
||||
id3_object.add_metadata(metadata)
|
||||
id3_object.save()
|
||||
|
||||
@@ -6,16 +6,18 @@ import re
|
||||
from .utils import cli_function
|
||||
from .options.first_config import initial_config
|
||||
|
||||
from ..utils import output, BColors
|
||||
from ..utils.config import write_config, main_settings
|
||||
from ..utils.shared import URL_PATTERN
|
||||
from ..utils.string_processing import fit_to_file_system
|
||||
from ..utils.support_classes.query import Query
|
||||
from ..utils.support_classes.download_result import DownloadResult
|
||||
from ..utils.exception import MKInvalidInputException
|
||||
from ..utils.exception.download import UrlNotFoundException
|
||||
from ..utils.enums.colors import BColors
|
||||
from .. import console
|
||||
|
||||
from ..download.results import Results, Option, PageResults
|
||||
from ..download.results import Results, Option, PageResults, GoToResults
|
||||
from ..download.page_attributes import Pages
|
||||
from ..pages import Page
|
||||
from ..objects import Song, Album, Artist, DatabaseObject
|
||||
@@ -164,9 +166,9 @@ class Downloader:
|
||||
self.genre = genre or get_genre()
|
||||
self.process_metadata_anyway = process_metadata_anyway
|
||||
|
||||
print()
|
||||
print(f"Downloading to: \"{self.genre}\"")
|
||||
print()
|
||||
output()
|
||||
output(f"Downloading to: \"{self.genre}\"", color=BColors.HEADER)
|
||||
output()
|
||||
|
||||
def print_current_options(self):
|
||||
self.page_dict = dict()
|
||||
@@ -174,7 +176,7 @@ class Downloader:
|
||||
print()
|
||||
|
||||
page_count = 0
|
||||
for option in self.current_results.formated_generator(max_items_per_page=self.max_displayed_options):
|
||||
for option in self.current_results.formatted_generator():
|
||||
if isinstance(option, Option):
|
||||
_downloadable = self.pages.is_downloadable(option.music_object)
|
||||
|
||||
@@ -249,7 +251,7 @@ class Downloader:
|
||||
f"Recommendations and suggestions on sites to implement appreciated.\n"
|
||||
f"But don't be a bitch if I don't end up implementing it.")
|
||||
return
|
||||
self.set_current_options(PageResults(page, data_object.options))
|
||||
self.set_current_options(PageResults(page, data_object.options, max_items_per_page=self.max_displayed_options))
|
||||
self.print_current_options()
|
||||
return
|
||||
|
||||
@@ -299,95 +301,117 @@ class Downloader:
|
||||
self.set_current_options(self.pages.search(parsed_query))
|
||||
self.print_current_options()
|
||||
|
||||
def goto(self, index: int):
|
||||
def goto(self, data_object: DatabaseObject):
|
||||
page: Type[Page]
|
||||
music_object: DatabaseObject
|
||||
|
||||
try:
|
||||
page, music_object = self.current_results.get_music_object_by_index(index)
|
||||
except KeyError:
|
||||
print()
|
||||
print(f"The option {index} doesn't exist.")
|
||||
print()
|
||||
return
|
||||
self.pages.fetch_details(data_object, stop_at_level=1)
|
||||
|
||||
self.pages.fetch_details(music_object)
|
||||
|
||||
print(music_object)
|
||||
print(music_object.options)
|
||||
self.set_current_options(PageResults(page, music_object.options))
|
||||
self.set_current_options(GoToResults(data_object.options, max_items_per_page=self.max_displayed_options))
|
||||
|
||||
self.print_current_options()
|
||||
|
||||
def download(self, download_str: str, download_all: bool = False) -> bool:
|
||||
to_download: List[DatabaseObject] = []
|
||||
|
||||
if re.match(URL_PATTERN, download_str) is not None:
|
||||
_, music_objects = self.pages.fetch_url(download_str)
|
||||
to_download.append(music_objects)
|
||||
|
||||
else:
|
||||
index: str
|
||||
for index in download_str.split(", "):
|
||||
if not index.strip().isdigit():
|
||||
print()
|
||||
print(f"Every download thingie has to be an index, not {index}.")
|
||||
print()
|
||||
return False
|
||||
|
||||
for index in download_str.split(", "):
|
||||
to_download.append(self.current_results.get_music_object_by_index(int(index))[1])
|
||||
|
||||
print()
|
||||
print("Downloading:")
|
||||
for download_object in to_download:
|
||||
print(download_object.option_string)
|
||||
print()
|
||||
def download(self, data_objects: List[DatabaseObject], **kwargs) -> bool:
|
||||
output()
|
||||
if len(data_objects) > 1:
|
||||
output(f"Downloading {len(data_objects)} objects...", *("- " + o.option_string for o in data_objects), color=BColors.BOLD, sep="\n")
|
||||
|
||||
_result_map: Dict[DatabaseObject, DownloadResult] = dict()
|
||||
|
||||
for database_object in to_download:
|
||||
r = self.pages.download(music_object=database_object, genre=self.genre, download_all=download_all,
|
||||
process_metadata_anyway=self.process_metadata_anyway)
|
||||
for database_object in data_objects:
|
||||
r = self.pages.download(
|
||||
music_object=database_object,
|
||||
genre=self.genre,
|
||||
**kwargs
|
||||
)
|
||||
_result_map[database_object] = r
|
||||
|
||||
for music_object, result in _result_map.items():
|
||||
print()
|
||||
print(music_object.option_string)
|
||||
print(result)
|
||||
output()
|
||||
output(music_object.option_string)
|
||||
output(result)
|
||||
|
||||
return True
|
||||
|
||||
def process_input(self, input_str: str) -> bool:
|
||||
input_str = input_str.strip()
|
||||
processed_input: str = input_str.lower()
|
||||
try:
|
||||
input_str = input_str.strip()
|
||||
processed_input: str = input_str.lower()
|
||||
|
||||
if processed_input in EXIT_COMMANDS:
|
||||
return True
|
||||
if processed_input in EXIT_COMMANDS:
|
||||
return True
|
||||
|
||||
if processed_input == ".":
|
||||
self.print_current_options()
|
||||
return False
|
||||
|
||||
if processed_input == "..":
|
||||
if self.previous_option():
|
||||
if processed_input == ".":
|
||||
self.print_current_options()
|
||||
return False
|
||||
|
||||
if processed_input == "..":
|
||||
if self.previous_option():
|
||||
self.print_current_options()
|
||||
return False
|
||||
|
||||
command = ""
|
||||
query = processed_input
|
||||
if ":" in processed_input:
|
||||
_ = processed_input.split(":")
|
||||
command, query = _[0], ":".join(_[1:])
|
||||
|
||||
do_search = "s" in command
|
||||
do_download = "d" in command
|
||||
do_merge = "m" in command
|
||||
|
||||
if do_search and do_download:
|
||||
raise MKInvalidInputException(message="You can't search and download at the same time.")
|
||||
|
||||
if do_search and do_merge:
|
||||
raise MKInvalidInputException(message="You can't search and merge at the same time.")
|
||||
|
||||
if do_search:
|
||||
self.search(":".join(input_str.split(":")[1:]))
|
||||
return False
|
||||
|
||||
indices = []
|
||||
for possible_index in query.split(","):
|
||||
possible_index = possible_index.strip()
|
||||
if possible_index == "":
|
||||
continue
|
||||
|
||||
i = 0
|
||||
try:
|
||||
i = int(possible_index)
|
||||
except ValueError:
|
||||
raise MKInvalidInputException(message=f"The index \"{possible_index}\" is not a number.")
|
||||
|
||||
if i < 0 or i >= len(self.current_results):
|
||||
raise MKInvalidInputException(message=f"The index \"{i}\" is not within the bounds of 0-{len(self.current_results) - 1}.")
|
||||
|
||||
indices.append(i)
|
||||
|
||||
selected_objects = [self.current_results[i] for i in indices]
|
||||
|
||||
if do_merge:
|
||||
old_selected_objects = selected_objects
|
||||
|
||||
a = old_selected_objects[0]
|
||||
for b in old_selected_objects[1:]:
|
||||
if type(a) != type(b):
|
||||
raise MKInvalidInputException(message="You can't merge different types of objects.")
|
||||
a.merge(b)
|
||||
|
||||
selected_objects = [a]
|
||||
|
||||
if do_download:
|
||||
self.download(selected_objects)
|
||||
return False
|
||||
|
||||
if len(selected_objects) != 1:
|
||||
raise MKInvalidInputException(message="You can only go to one object at a time without merging.")
|
||||
|
||||
self.goto(selected_objects[0])
|
||||
return False
|
||||
except MKInvalidInputException as e:
|
||||
output("\n" + e.message + "\n", color=BColors.FAIL)
|
||||
help_message()
|
||||
|
||||
if processed_input.startswith("s: "):
|
||||
self.search(input_str[3:])
|
||||
return False
|
||||
|
||||
if processed_input.startswith("d: "):
|
||||
return self.download(input_str[3:])
|
||||
|
||||
if processed_input.isdigit():
|
||||
self.goto(int(processed_input))
|
||||
return False
|
||||
|
||||
if processed_input != "help":
|
||||
print(f"{BColors.WARNING.value}Invalid input.{BColors.ENDC.value}")
|
||||
help_message()
|
||||
return False
|
||||
|
||||
def mainloop(self):
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..utils.config import main_settings
|
||||
from ..utils.enums.album import AlbumType
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchOptions:
|
||||
download_all: bool = False
|
||||
album_type_blacklist: Set[AlbumType] = field(default_factory=lambda: set(AlbumType(a) for a in main_settings["album_type_blacklist"]))
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadOptions:
|
||||
download_all: bool = False
|
||||
album_type_blacklist: Set[AlbumType] = field(default_factory=lambda: set(AlbumType(a) for a in main_settings["album_type_blacklist"]))
|
||||
|
||||
download_again_if_found: bool = False
|
||||
process_audio_if_found: bool = False
|
||||
process_metadata_if_found: bool = True
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
from typing import Tuple, Type, Dict, Set
|
||||
from typing import Tuple, Type, Dict, Set, Optional, List
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from . import FetchOptions, DownloadOptions
|
||||
from .results import SearchResults
|
||||
from ..objects import DatabaseObject, Source
|
||||
|
||||
from ..utils.config import youtube_settings
|
||||
from ..utils.enums.source import SourcePages
|
||||
from ..objects import (
|
||||
DatabaseObject as DataObject,
|
||||
Collection,
|
||||
Target,
|
||||
Source,
|
||||
Options,
|
||||
Song,
|
||||
Album,
|
||||
Artist,
|
||||
Label,
|
||||
)
|
||||
from ..utils.string_processing import fit_to_file_system
|
||||
from ..utils.config import youtube_settings, main_settings
|
||||
from ..utils.path_manager import LOCATIONS
|
||||
from ..utils.enums import SourceType
|
||||
from ..utils.support_classes.download_result import DownloadResult
|
||||
from ..utils.support_classes.query import Query
|
||||
from ..utils.support_classes.download_result import DownloadResult
|
||||
from ..utils.exception import MKMissingNameException
|
||||
from ..utils.exception.download import UrlNotFoundException
|
||||
from ..utils.shared import DEBUG_PAGES
|
||||
|
||||
@@ -34,6 +51,13 @@ SHADY_PAGES: Set[Type[Page]] = {
|
||||
Musify,
|
||||
}
|
||||
|
||||
fetch_map = {
|
||||
Song: "fetch_song",
|
||||
Album: "fetch_album",
|
||||
Artist: "fetch_artist",
|
||||
Label: "fetch_label",
|
||||
}
|
||||
|
||||
if DEBUG_PAGES:
|
||||
DEBUGGING_PAGE = Bandcamp
|
||||
print(f"Only downloading from page {DEBUGGING_PAGE}.")
|
||||
@@ -43,10 +67,13 @@ if DEBUG_PAGES:
|
||||
|
||||
|
||||
class Pages:
|
||||
def __init__(self, exclude_pages: Set[Type[Page]] = None, exclude_shady: bool = False) -> None:
|
||||
def __init__(self, exclude_pages: Set[Type[Page]] = None, exclude_shady: bool = False, download_options: DownloadOptions = None, fetch_options: FetchOptions = None):
|
||||
self.download_options: DownloadOptions = download_options or DownloadOptions()
|
||||
self.fetch_options: FetchOptions = fetch_options or FetchOptions()
|
||||
|
||||
# initialize all page instances
|
||||
self._page_instances: Dict[Type[Page], Page] = dict()
|
||||
self._source_to_page: Dict[SourcePages, Type[Page]] = dict()
|
||||
self._source_to_page: Dict[SourceType, Type[Page]] = dict()
|
||||
|
||||
exclude_pages = exclude_pages if exclude_pages is not None else set()
|
||||
|
||||
@@ -61,14 +88,19 @@ class Pages:
|
||||
|
||||
self._pages_set: Set[Type[Page]] = ALL_PAGES.difference(exclude_pages)
|
||||
self.pages: Tuple[Type[Page], ...] = _set_to_tuple(self._pages_set)
|
||||
|
||||
|
||||
self._audio_pages_set: Set[Type[Page]] = self._pages_set.intersection(AUDIO_PAGES)
|
||||
self.audio_pages: Tuple[Type[Page], ...] = _set_to_tuple(self._audio_pages_set)
|
||||
|
||||
for page_type in self.pages:
|
||||
self._page_instances[page_type] = page_type()
|
||||
self._page_instances[page_type] = page_type(fetch_options=self.fetch_options, download_options=self.download_options)
|
||||
self._source_to_page[page_type.SOURCE_TYPE] = page_type
|
||||
|
||||
|
||||
def _get_page_from_enum(self, source_page: SourceType) -> Page:
|
||||
if source_page not in self._source_to_page:
|
||||
return None
|
||||
return self._page_instances[self._source_to_page[source_page]]
|
||||
|
||||
def search(self, query: Query) -> SearchResults:
|
||||
result = SearchResults()
|
||||
|
||||
@@ -80,22 +112,42 @@ class Pages:
|
||||
|
||||
return result
|
||||
|
||||
def fetch_details(self, music_object: DatabaseObject, stop_at_level: int = 1) -> DatabaseObject:
|
||||
if not isinstance(music_object, INDEPENDENT_DB_OBJECTS):
|
||||
return music_object
|
||||
def fetch_details(self, data_object: DataObject, stop_at_level: int = 1, **kwargs) -> DataObject:
|
||||
if not isinstance(data_object, INDEPENDENT_DB_OBJECTS):
|
||||
return data_object
|
||||
|
||||
for source_page in music_object.source_collection.source_pages:
|
||||
if source_page not in self._source_to_page:
|
||||
continue
|
||||
source: Source
|
||||
for source in data_object.source_collection.get_sources():
|
||||
new_data_object = self.fetch_from_source(source=source, stop_at_level=stop_at_level)
|
||||
if new_data_object is not None:
|
||||
data_object.merge(new_data_object)
|
||||
|
||||
page_type = self._source_to_page[source_page]
|
||||
|
||||
if page_type in self._pages_set:
|
||||
music_object.merge(self._page_instances[page_type].fetch_details(music_object=music_object, stop_at_level=stop_at_level))
|
||||
return data_object
|
||||
|
||||
def fetch_from_source(self, source: Source, **kwargs) -> Optional[DataObject]:
|
||||
page: Page = self._get_page_from_enum(source.source_type)
|
||||
if page is None:
|
||||
return None
|
||||
|
||||
return music_object
|
||||
# getting the appropriate function for the page and the object type
|
||||
source_type = page.get_source_type(source)
|
||||
if not hasattr(page, fetch_map[source_type]):
|
||||
return None
|
||||
func = getattr(page, fetch_map[source_type])(source=source, **kwargs)
|
||||
|
||||
# fetching the data object and marking it as fetched
|
||||
data_object: DataObject = func(source=source)
|
||||
data_object.mark_as_fetched(source.hash_url)
|
||||
return data_object
|
||||
|
||||
def is_downloadable(self, music_object: DatabaseObject) -> bool:
|
||||
def fetch_from_url(self, url: str) -> Optional[DataObject]:
|
||||
source = Source.match_url(url, SourceType.MANUAL)
|
||||
if source is None:
|
||||
return None
|
||||
|
||||
return self.fetch_from_source(source=source)
|
||||
|
||||
def is_downloadable(self, music_object: DataObject) -> bool:
|
||||
_page_types = set(self._source_to_page)
|
||||
for src in music_object.source_collection.source_pages:
|
||||
if src in self._source_to_page:
|
||||
@@ -104,30 +156,139 @@ class Pages:
|
||||
audio_pages = self._audio_pages_set.intersection(_page_types)
|
||||
return len(audio_pages) > 0
|
||||
|
||||
def download(self, music_object: DatabaseObject, genre: str, download_all: bool = False, process_metadata_anyway: bool = False) -> DownloadResult:
|
||||
if not isinstance(music_object, INDEPENDENT_DB_OBJECTS):
|
||||
return DownloadResult(error_message=f"{type(music_object).__name__} can't be downloaded.")
|
||||
|
||||
self.fetch_details(music_object)
|
||||
|
||||
_page_types = set(self._source_to_page)
|
||||
for src in music_object.source_collection.source_pages:
|
||||
if src in self._source_to_page:
|
||||
_page_types.add(self._source_to_page[src])
|
||||
|
||||
audio_pages = self._audio_pages_set.intersection(_page_types)
|
||||
def _skip_object(self, data_object: DataObject) -> bool:
|
||||
if isinstance(data_object, Album):
|
||||
if not self.download_options.download_all and data_object.album_type in self.download_options.album_type_blacklist:
|
||||
return True
|
||||
|
||||
for download_page in audio_pages:
|
||||
return self._page_instances[download_page].download(music_object=music_object, genre=genre, download_all=download_all, process_metadata_anyway=process_metadata_anyway)
|
||||
|
||||
return DownloadResult(error_message=f"No audio source has been found for {music_object}.")
|
||||
return False
|
||||
|
||||
def fetch_url(self, url: str, stop_at_level: int = 2) -> Tuple[Type[Page], DatabaseObject]:
|
||||
source = Source.match_url(url, SourcePages.MANUAL)
|
||||
def download(self, data_object: DataObject, genre: str, **kwargs) -> DownloadResult:
|
||||
# fetch the given object
|
||||
self.fetch_details(data_object)
|
||||
|
||||
# fetching all parent objects (e.g. if you only download a song)
|
||||
if not kwargs.get("fetched_upwards", False):
|
||||
to_fetch: List[DataObject] = [data_object]
|
||||
|
||||
while len(to_fetch) > 0:
|
||||
new_to_fetch = []
|
||||
for d in to_fetch:
|
||||
if self._skip_object(d):
|
||||
continue
|
||||
|
||||
self.fetch_details(d)
|
||||
|
||||
for c in d.get_parent_collections():
|
||||
new_to_fetch.extend(c)
|
||||
|
||||
to_fetch = new_to_fetch
|
||||
|
||||
kwargs["fetched_upwards"] = True
|
||||
|
||||
# download all children
|
||||
download_result: DownloadResult = DownloadResult()
|
||||
for c in data_object.get_children():
|
||||
for d in c:
|
||||
if self._skip_object(d):
|
||||
continue
|
||||
|
||||
download_result.merge(self.download(d, genre, **kwargs))
|
||||
|
||||
# actually download if the object is a song
|
||||
if isinstance(data_object, Song):
|
||||
"""
|
||||
TODO
|
||||
add the traced artist and album to the naming.
|
||||
I am able to do that, because duplicate values are removed later on.
|
||||
"""
|
||||
|
||||
self._download_song(data_object, naming={
|
||||
"genre": [genre],
|
||||
"audio_format": main_settings["audio_format"],
|
||||
})
|
||||
|
||||
return download_result
|
||||
|
||||
def _extract_fields_from_template(self, path_template: str) -> Set[str]:
|
||||
return set(re.findall(r"{([^}]+)}", path_template))
|
||||
|
||||
def _parse_path_template(self, path_template: str, naming: Dict[str, List[str]]) -> str:
|
||||
field_names: Set[str] = self._extract_fields_from_template(path_template)
|
||||
|
||||
for field in field_names:
|
||||
if len(naming[field]) == 0:
|
||||
raise MKMissingNameException(f"Missing field for {field}.")
|
||||
|
||||
path_template = path_template.replace(f"{{{field}}}", naming[field][0])
|
||||
|
||||
return possible_parts
|
||||
|
||||
def _get_pages_with_source(self, data_object: DataObject, sort_by_attribute: str = "DOWNLOAD_PRIORITY") -> List[Page]:
|
||||
pages = [self._get_page_from_enum(s.source_type) for s in data_object.source_collection.get_sources()]
|
||||
pages.sort(key=lambda p: getattr(p, sort_by_attribute), reverse=True)
|
||||
return list(pages)
|
||||
|
||||
def _download_song(self, song: Song, naming: dict) -> DownloadOptions:
|
||||
"""
|
||||
TODO
|
||||
Search the song in the file system.
|
||||
"""
|
||||
r = DownloadResult(total=1)
|
||||
|
||||
# pre process the data recursively
|
||||
song.compile()
|
||||
|
||||
# manage the naming
|
||||
naming: Dict[str, List[str]] = defaultdict(list, naming)
|
||||
naming["song"].append(song.title_string)
|
||||
naming["genre"].append(song.genre)
|
||||
naming["isrc"].append(song.isrc)
|
||||
naming["album"].extend(a.title_string for a in song.album_collection)
|
||||
naming["album_type"].extend(a.album_type.value for a in song.album_collection)
|
||||
naming["artist"].extend(a.name for a in song.main_artist_collection)
|
||||
naming["artist"].extend(a.name for a in song.feature_artist_collection)
|
||||
for a in song.album_collection:
|
||||
naming["label"].extend([l.title_string for l in a.label_collection])
|
||||
# removing duplicates from the naming, and process the strings
|
||||
for key, value in naming.items():
|
||||
# https://stackoverflow.com/a/17016257
|
||||
naming[key] = list(dict.fromkeys(items))
|
||||
|
||||
# manage the targets
|
||||
tmp: Target = Target.temp(file_extension=main_settings["audio_format"])
|
||||
found_on_disc = False
|
||||
|
||||
song.target_collection.append(Target(
|
||||
relative_to_music_dir=True,
|
||||
file_path=Path(
|
||||
self._parse_path_template(main_settings["download_path"], naming=naming),
|
||||
self._parse_path_template(main_settings["download_file"], naming=naming),
|
||||
)
|
||||
))
|
||||
for target in song.target_collection:
|
||||
if target.exists():
|
||||
output(f'- {target.file_path} {BColors.OKGREEN.value}[already exists]', color=BColors.GREY)
|
||||
|
||||
found_on_disc = True
|
||||
r.found_on_disk += 1
|
||||
target.copy_content(tmp)
|
||||
else:
|
||||
target.create_parent_directories()
|
||||
output(f'- {target.file_path}', color=BColors.GREY)
|
||||
|
||||
# actually download
|
||||
for page in self._get_pages_with_source(song, sort_by_attribute="DOWNLOAD_PRIORITY"):
|
||||
r = page.download_song_to_target(song, tmp, r)
|
||||
|
||||
return r
|
||||
|
||||
def fetch_url(self, url: str, stop_at_level: int = 2) -> Tuple[Type[Page], DataObject]:
|
||||
source = Source.match_url(url, SourceType.MANUAL)
|
||||
|
||||
if source is None:
|
||||
raise UrlNotFoundException(url=url)
|
||||
|
||||
_actual_page = self._source_to_page[source.page_enum]
|
||||
_actual_page = self._source_to_page[source.source_type]
|
||||
|
||||
return _actual_page, self._page_instances[_actual_page].fetch_object_from_source(source=source, stop_at_level=stop_at_level)
|
||||
@@ -2,7 +2,7 @@ from typing import Tuple, Type, Dict, List, Generator, Union
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..objects import DatabaseObject
|
||||
from ..utils.enums.source import SourcePages
|
||||
from ..utils.enums.source import SourceType
|
||||
from ..pages import Page, EncyclopaediaMetallum, Musify
|
||||
|
||||
|
||||
@@ -13,31 +13,35 @@ class Option:
|
||||
|
||||
|
||||
class Results:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, max_items_per_page: int = 10, **kwargs) -> None:
|
||||
self._by_index: Dict[int, DatabaseObject] = dict()
|
||||
self._page_by_index: Dict[int: Type[Page]] = dict()
|
||||
|
||||
self.max_items_per_page = max_items_per_page
|
||||
|
||||
def __iter__(self) -> Generator[DatabaseObject, None, None]:
|
||||
for option in self.formated_generator():
|
||||
for option in self.formatted_generator():
|
||||
if isinstance(option, Option):
|
||||
yield option.music_object
|
||||
|
||||
def formated_generator(self, max_items_per_page: int = 10) -> Generator[Union[Type[Page], Option], None, None]:
|
||||
def formatted_generator(self) -> Generator[Union[Type[Page], Option], None, None]:
|
||||
self._by_index = dict()
|
||||
self._page_by_index = dict()
|
||||
|
||||
def get_music_object_by_index(self, index: int) -> Tuple[Type[Page], DatabaseObject]:
|
||||
# if this throws a key error, either the formatted generator needs to be iterated, or the option doesn't exist.
|
||||
return self._page_by_index[index], self._by_index[index]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return max(self._by_index.keys())
|
||||
|
||||
def __getitem__(self, index: int):
|
||||
return self._by_index[index]
|
||||
|
||||
|
||||
class SearchResults(Results):
|
||||
def __init__(
|
||||
self,
|
||||
pages: Tuple[Type[Page], ...] = None
|
||||
|
||||
pages: Tuple[Type[Page], ...] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.pages = pages or []
|
||||
# this would initialize a list for every page, which I don't think I want
|
||||
@@ -54,9 +58,12 @@ class SearchResults(Results):
|
||||
|
||||
def get_page_results(self, page: Type[Page]) -> "PageResults":
|
||||
return PageResults(page, self.results.get(page, []))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return sum(min(self.max_items_per_page, len(results)) for results in self.results.values())
|
||||
|
||||
def formated_generator(self, max_items_per_page: int = 10):
|
||||
super().formated_generator()
|
||||
def formatted_generator(self):
|
||||
super().formatted_generator()
|
||||
i = 0
|
||||
|
||||
for page in self.results:
|
||||
@@ -70,19 +77,37 @@ class SearchResults(Results):
|
||||
i += 1
|
||||
j += 1
|
||||
|
||||
if j >= max_items_per_page:
|
||||
if j >= self.max_items_per_page:
|
||||
break
|
||||
|
||||
|
||||
class GoToResults(Results):
|
||||
def __init__(self, results: List[DatabaseObject], **kwargs):
|
||||
self.results: List[DatabaseObject] = results
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __getitem__(self, index: int):
|
||||
return self.results[index]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.results)
|
||||
|
||||
def formatted_generator(self):
|
||||
yield from (Option(i, o) for i, o in enumerate(self.results))
|
||||
|
||||
|
||||
|
||||
class PageResults(Results):
|
||||
def __init__(self, page: Type[Page], results: List[DatabaseObject]) -> None:
|
||||
super().__init__()
|
||||
def __init__(self, page: Type[Page], results: List[DatabaseObject], **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.page: Type[Page] = page
|
||||
self.results: List[DatabaseObject] = results
|
||||
|
||||
|
||||
def formated_generator(self, max_items_per_page: int = 10):
|
||||
super().formated_generator()
|
||||
def formatted_generator(self, max_items_per_page: int = 10):
|
||||
super().formatted_generator()
|
||||
i = 0
|
||||
|
||||
yield self.page
|
||||
@@ -92,3 +117,6 @@ class PageResults(Results):
|
||||
self._by_index[i] = option
|
||||
self._page_by_index[i] = self.page
|
||||
i += 1
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.results)
|
||||
|
||||
@@ -3,7 +3,7 @@ from .option import Options
|
||||
|
||||
from .metadata import Metadata, Mapping as ID3Mapping, ID3Timestamp
|
||||
|
||||
from .source import Source, SourcePages, SourceTypes
|
||||
from .source import Source, SourceType
|
||||
|
||||
from .song import (
|
||||
Song,
|
||||
@@ -24,4 +24,4 @@ from .parents import OuterProxy
|
||||
|
||||
from .artwork import Artwork
|
||||
|
||||
DatabaseObject = TypeVar('T', bound=OuterProxy)
|
||||
DatabaseObject = OuterProxy
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TypeVar, Generic, Dict, Optional, Iterable, List, Iterator, Tuple, Generator, Union, Any, Set
|
||||
import copy
|
||||
|
||||
from .parents import OuterProxy
|
||||
from ..utils import object_trace
|
||||
from ..utils import output, BColors
|
||||
@@ -47,8 +49,15 @@ class Collection(Generic[T]):
|
||||
|
||||
self.extend(data)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return id(self)
|
||||
|
||||
@property
|
||||
def collection_names(self) -> List[str]:
|
||||
return list(set(self._collection_for.values()))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Collection({' | '.join(self._collection_for.values())} {id(self)})"
|
||||
return f"Collection({' | '.join(self.collection_names)} {id(self)})"
|
||||
|
||||
def _map_element(self, __object: T, no_unmap: bool = False, **kwargs):
|
||||
if not no_unmap:
|
||||
@@ -104,8 +113,9 @@ class Collection(Generic[T]):
|
||||
"""
|
||||
|
||||
self._data.append(other)
|
||||
other._inner._is_in_collection.add(self)
|
||||
|
||||
# all of the existing hooks to get the defined datastructure
|
||||
# all of the existing hooks to get the defined datastructures
|
||||
for collection_attribute, generator in self.extend_object_to_attribute.items():
|
||||
other.__getattribute__(collection_attribute).extend(generator, **kwargs)
|
||||
|
||||
@@ -143,35 +153,35 @@ class Collection(Generic[T]):
|
||||
|
||||
if other is None:
|
||||
return
|
||||
if not other._inner._has_data:
|
||||
return
|
||||
if other.id in self._indexed_from_id:
|
||||
return
|
||||
|
||||
object_trace(f"Appending {other.option_string} to {self}")
|
||||
|
||||
|
||||
for c in self.pull_from:
|
||||
r = c._find_object(other)
|
||||
if r is not None:
|
||||
output("found pull from", r, other, self, color=BColors.RED, sep="\t")
|
||||
other.merge(r, **kwargs)
|
||||
c.remove(r, existing=r, **kwargs)
|
||||
break
|
||||
|
||||
existing_object = self._find_object(other)
|
||||
|
||||
# switching collection in the case of push to
|
||||
for c in self.push_to:
|
||||
r = c._find_object(other)
|
||||
if r is not None:
|
||||
output("found push to", r, other, self, color=BColors.RED, sep="\t")
|
||||
# output("found push to", r, other, c, self, color=BColors.RED, sep="\t")
|
||||
return c.append(other, **kwargs)
|
||||
|
||||
for c in self.pull_from:
|
||||
r = c._find_object(other)
|
||||
if r is not None:
|
||||
# output("found pull from", r, other, c, self, color=BColors.RED, sep="\t")
|
||||
c.remove(r, existing=r, **kwargs)
|
||||
|
||||
if existing_object is None:
|
||||
existing = self._find_object(other)
|
||||
|
||||
if existing is None:
|
||||
self._append_new_object(other, **kwargs)
|
||||
else:
|
||||
existing_object.merge(other, **kwargs)
|
||||
existing.merge(other, **kwargs)
|
||||
|
||||
def remove(self, *other_list: List[T], silent: bool = False, existing: Optional[T] = None, **kwargs):
|
||||
def remove(self, *other_list: List[T], silent: bool = False, existing: Optional[T] = None, remove_from_other_collection=True, **kwargs):
|
||||
other: T
|
||||
for other in other_list:
|
||||
existing: Optional[T] = existing or self._indexed_values["id"].get(other.id, None)
|
||||
if existing is None:
|
||||
@@ -179,16 +189,13 @@ class Collection(Generic[T]):
|
||||
raise ValueError(f"Object {other} not found in {self}")
|
||||
return other
|
||||
|
||||
"""
|
||||
for collection_attribute, generator in self.extend_object_to_attribute.items():
|
||||
other.__getattribute__(collection_attribute).remove(*generator, silent=silent, **kwargs)
|
||||
|
||||
for attribute, new_object in self.append_object_to_attribute.items():
|
||||
other.__getattribute__(attribute).remove(new_object, silent=silent, **kwargs)
|
||||
"""
|
||||
|
||||
self._data.remove(existing)
|
||||
self._unmap_element(existing)
|
||||
if remove_from_other_collection:
|
||||
for c in copy.copy(other._inner._is_in_collection):
|
||||
c.remove(other, silent=True, remove_from_other_collection=False, **kwargs)
|
||||
other._inner._is_in_collection = set()
|
||||
else:
|
||||
self._data.remove(existing)
|
||||
self._unmap_element(existing)
|
||||
|
||||
def contains(self, __object: T) -> bool:
|
||||
return self._find_object(__object) is not None
|
||||
|
||||
@@ -32,7 +32,7 @@ class FormattedText:
|
||||
if self.is_empty and other.is_empty:
|
||||
return True
|
||||
|
||||
return self.doc == other.doc
|
||||
return self.html == other.html
|
||||
|
||||
@property
|
||||
def markdown(self) -> str:
|
||||
|
||||
@@ -92,7 +92,7 @@ class Mapping(Enum):
|
||||
key = attribute.value
|
||||
|
||||
if key[0] == 'T':
|
||||
# a text fiel
|
||||
# a text field
|
||||
return cls.get_text_instance(key, value)
|
||||
if key[0] == "W":
|
||||
# an url field
|
||||
@@ -355,7 +355,12 @@ class Metadata:
|
||||
return None
|
||||
|
||||
list_data = self.id3_dict[field]
|
||||
|
||||
#correct duplications
|
||||
correct_list_data = list()
|
||||
for data in list_data:
|
||||
if data not in correct_list_data:
|
||||
correct_list_data.append(data)
|
||||
list_data = correct_list_data
|
||||
# convert for example the time objects to timestamps
|
||||
for i, element in enumerate(list_data):
|
||||
# for performance’s sake I don't do other checks if it is already the right type
|
||||
@@ -368,7 +373,7 @@ class Metadata:
|
||||
if type(element) == ID3Timestamp:
|
||||
list_data[i] = element.timestamp
|
||||
continue
|
||||
|
||||
|
||||
"""
|
||||
Version 2.4 of the specification prescribes that all text fields (the fields that start with a T, except for TXXX) can contain multiple values separated by a null character.
|
||||
Thus if above conditions are met, I concatenate the list,
|
||||
@@ -376,7 +381,7 @@ class Metadata:
|
||||
"""
|
||||
if field.value[0].upper() == "T" and field.value.upper() != "TXXX":
|
||||
return self.NULL_BYTE.join(list_data)
|
||||
|
||||
|
||||
return list_data[0]
|
||||
|
||||
def get_mutagen_object(self, field):
|
||||
@@ -395,6 +400,5 @@ class Metadata:
|
||||
"""
|
||||
# set the tagging timestamp to the current time
|
||||
self.__setitem__(Mapping.TAGGING_TIME, [ID3Timestamp.now()])
|
||||
|
||||
for field in self.id3_dict:
|
||||
yield self.get_mutagen_object(field)
|
||||
|
||||
@@ -29,12 +29,17 @@ class InnerData:
|
||||
"""
|
||||
|
||||
_refers_to_instances: set = None
|
||||
_is_in_collection: set = None
|
||||
|
||||
_has_data: bool = False
|
||||
"""
|
||||
Attribute versions keep track, of if the attribute has been changed.
|
||||
"""
|
||||
|
||||
def __init__(self, object_type, **kwargs):
|
||||
self._refers_to_instances = set()
|
||||
self._is_in_collection = set()
|
||||
|
||||
self._fetched_from: dict = {}
|
||||
|
||||
# initialize the default values
|
||||
@@ -45,9 +50,19 @@ class InnerData:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(value, "__is_collection__"):
|
||||
value._collection_for[self] = key
|
||||
|
||||
|
||||
self.__setattr__(key, value)
|
||||
|
||||
if self._has_data:
|
||||
continue
|
||||
|
||||
def __setattr__(self, key: str, value):
|
||||
if self._has_data or not hasattr(self, "_default_values"):
|
||||
return super().__setattr__(key, value)
|
||||
|
||||
super().__setattr__("_has_data", not (key in self._default_values and self._default_values[key] == value))
|
||||
return super().__setattr__(key, value)
|
||||
|
||||
def __hash__(self):
|
||||
return self.id
|
||||
|
||||
@@ -58,6 +73,7 @@ class InnerData:
|
||||
"""
|
||||
|
||||
self._fetched_from.update(__other._fetched_from)
|
||||
self._is_in_collection.update(__other._is_in_collection)
|
||||
|
||||
for key, value in __other.__dict__.copy().items():
|
||||
if key.startswith("_"):
|
||||
@@ -83,7 +99,9 @@ class OuterProxy:
|
||||
Wraps the inner data, and provides apis, to naturally access those values.
|
||||
"""
|
||||
|
||||
_default_factories: dict = {}
|
||||
source_collection: SourceCollection
|
||||
|
||||
_default_factories: dict = {"source_collection": SourceCollection}
|
||||
_outer_attribute: Set[str] = {"options", "metadata", "indexing_values", "option_string"}
|
||||
|
||||
DOWNWARDS_COLLECTION_STRING_ATTRIBUTES = tuple()
|
||||
@@ -187,6 +205,7 @@ class OuterProxy:
|
||||
if __other is None:
|
||||
return
|
||||
|
||||
a_id = self.id
|
||||
|
||||
a = self
|
||||
b = __other
|
||||
@@ -209,6 +228,8 @@ class OuterProxy:
|
||||
a._inner.__merge__(old_inner, **kwargs)
|
||||
del old_inner
|
||||
|
||||
self.id = a_id
|
||||
|
||||
def __merge__(self, __other: Optional[OuterProxy], **kwargs):
|
||||
self.merge(__other, **kwargs)
|
||||
|
||||
@@ -285,6 +306,33 @@ class OuterProxy:
|
||||
|
||||
return r
|
||||
|
||||
@property
|
||||
def root_collections(self) -> List[Collection]:
|
||||
if len(self.UPWARDS_COLLECTION_STRING_ATTRIBUTES) == 0:
|
||||
return [self]
|
||||
|
||||
r = []
|
||||
for collection_string_attribute in self.UPWARDS_COLLECTION_STRING_ATTRIBUTES:
|
||||
r.extend(self.__getattribute__(collection_string_attribute))
|
||||
|
||||
return r
|
||||
|
||||
def _compile(self, **kwargs):
|
||||
pass
|
||||
|
||||
def compile(self, from_root=False, **kwargs):
|
||||
# compile from the root
|
||||
if not from_root:
|
||||
for c in self.root_collections:
|
||||
c.compile(from_root=True, **kwargs)
|
||||
return
|
||||
|
||||
self._compile(**kwargs)
|
||||
|
||||
for c_attribute in self.DOWNWARDS_COLLECTION_STRING_ATTRIBUTES:
|
||||
for c in self.__getattribute__(c_attribute):
|
||||
c.compile(from_root=True, **kwargs)
|
||||
|
||||
TITEL = "id"
|
||||
@property
|
||||
def title_string(self) -> str:
|
||||
@@ -292,3 +340,11 @@ class OuterProxy:
|
||||
|
||||
def __repr__(self):
|
||||
return f"{type(self).__name__}({self.title_string})"
|
||||
|
||||
def get_child_collections(self):
|
||||
for collection_string_attribute in self.DOWNWARDS_COLLECTION_STRING_ATTRIBUTES:
|
||||
yield self.__getattribute__(collection_string_attribute)
|
||||
|
||||
def get_parent_collections(self):
|
||||
for collection_string_attribute in self.UPWARDS_COLLECTION_STRING_ATTRIBUTES:
|
||||
yield self.__getattribute__(collection_string_attribute)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Dict, Tuple, Type, Union
|
||||
import copy
|
||||
|
||||
import pycountry
|
||||
|
||||
@@ -110,7 +111,7 @@ class Song(Base):
|
||||
"album_collection": Collection,
|
||||
"feature_artist_collection": Collection,
|
||||
|
||||
"title": lambda: "",
|
||||
"title": lambda: None,
|
||||
"unified_title": lambda: None,
|
||||
"isrc": lambda: None,
|
||||
"genre": lambda: None,
|
||||
@@ -118,13 +119,27 @@ class Song(Base):
|
||||
"tracksort": lambda: 0,
|
||||
}
|
||||
|
||||
def __init__(self, title: str = "", unified_title: str = None, isrc: str = None, length: int = None,
|
||||
genre: str = None, note: FormattedText = None, source_list: List[Source] = None,
|
||||
target_list: List[Target] = None, lyrics_list: List[Lyrics] = None,
|
||||
main_artist_list: List[Artist] = None, feature_artist_list: List[Artist] = None,
|
||||
album_list: List[Album] = None, tracksort: int = 0, artwork: Optional[Artwork] = None, **kwargs) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
title: str = None,
|
||||
isrc: str = None,
|
||||
length: int = None,
|
||||
genre: str = None,
|
||||
note: FormattedText = None,
|
||||
source_list: List[Source] = None,
|
||||
target_list: List[Target] = None,
|
||||
lyrics_list: List[Lyrics] = None,
|
||||
main_artist_list: List[Artist] = None,
|
||||
feature_artist_list: List[Artist] = None,
|
||||
album_list: List[Album] = None,
|
||||
tracksort: int = 0,
|
||||
artwork: Optional[Artwork] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
real_kwargs = copy.copy(locals())
|
||||
real_kwargs.update(real_kwargs.pop("kwargs", {}))
|
||||
|
||||
Base.__init__(**locals())
|
||||
Base.__init__(**real_kwargs)
|
||||
|
||||
UPWARDS_COLLECTION_STRING_ATTRIBUTES = ("main_artist_collection", "feature_artist_collection", "album_collection")
|
||||
TITEL = "title"
|
||||
@@ -210,14 +225,6 @@ class Song(Base):
|
||||
r += get_collection_string(self.feature_artist_collection, " feat. {}")
|
||||
return r
|
||||
|
||||
@property
|
||||
def options(self) -> List[P]:
|
||||
options = self.main_artist_collection.shallow_list
|
||||
options.extend(self.feature_artist_collection)
|
||||
options.extend(self.album_collection)
|
||||
options.append(self)
|
||||
return options
|
||||
|
||||
@property
|
||||
def tracksort_str(self) -> str:
|
||||
"""
|
||||
@@ -273,15 +280,27 @@ class Album(Base):
|
||||
TITEL = "title"
|
||||
|
||||
# This is automatically generated
|
||||
def __init__(self, title: str = None, unified_title: str = None, album_status: AlbumStatus = None,
|
||||
album_type: AlbumType = None, language: Language = None, date: ID3Timestamp = None,
|
||||
barcode: str = None, albumsort: int = None, notes: FormattedText = None,
|
||||
source_list: List[Source] = None, artist_list: List[Artist] = None, song_list: List[Song] = None,
|
||||
label_list: List[Label] = None, **kwargs) -> None:
|
||||
super().__init__(title=title, unified_title=unified_title, album_status=album_status, album_type=album_type,
|
||||
language=language, date=date, barcode=barcode, albumsort=albumsort, notes=notes,
|
||||
source_list=source_list, artist_list=artist_list, song_list=song_list, label_list=label_list,
|
||||
**kwargs)
|
||||
def __init__(
|
||||
self,
|
||||
title: str = None,
|
||||
unified_title: str = None,
|
||||
album_status: AlbumStatus = None,
|
||||
album_type: AlbumType = None,
|
||||
language: Language = None,
|
||||
date: ID3Timestamp = None,
|
||||
barcode: str = None,
|
||||
albumsort: int = None,
|
||||
notes: FormattedText = None,
|
||||
source_list: List[Source] = None,
|
||||
artist_list: List[Artist] = None,
|
||||
song_list: List[Song] = None,
|
||||
label_list: List[Label] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
real_kwargs = copy.copy(locals())
|
||||
real_kwargs.update(real_kwargs.pop("kwargs", {}))
|
||||
|
||||
Base.__init__(**real_kwargs)
|
||||
|
||||
DOWNWARDS_COLLECTION_STRING_ATTRIBUTES = ("song_collection",)
|
||||
UPWARDS_COLLECTION_STRING_ATTRIBUTES = ("label_collection", "artist_collection")
|
||||
@@ -357,6 +376,25 @@ class Album(Base):
|
||||
r += f" with {len(self.song_collection)} songs"
|
||||
return r
|
||||
|
||||
def _compile(self):
|
||||
self.analyze_implied_album_type()
|
||||
self.update_tracksort()
|
||||
|
||||
def analyze_implied_album_type(self):
|
||||
# if the song collection has only one song, it is reasonable to assume that it is a single
|
||||
if len(self.song_collection) == 1:
|
||||
self.album_type = AlbumType.SINGLE
|
||||
return
|
||||
|
||||
# if the album already has an album type, we don't need to do anything
|
||||
if self.album_type is not AlbumType.OTHER:
|
||||
return
|
||||
|
||||
# for information on EP's I looked at https://www.reddit.com/r/WeAreTheMusicMakers/comments/a354ql/whats_the_cutoff_length_between_ep_and_album/
|
||||
if len(self.song_collection) < 9:
|
||||
self.album_type = AlbumType.EP
|
||||
return
|
||||
|
||||
def update_tracksort(self):
|
||||
"""
|
||||
This updates the tracksort attributes, of the songs in
|
||||
@@ -413,14 +451,8 @@ class Album(Base):
|
||||
return self.album_type.value
|
||||
|
||||
|
||||
"""
|
||||
All objects dependent on Artist
|
||||
"""
|
||||
|
||||
|
||||
class Artist(Base):
|
||||
name: str
|
||||
unified_name: str
|
||||
country: Country
|
||||
formed_in: ID3Timestamp
|
||||
notes: FormattedText
|
||||
@@ -437,8 +469,7 @@ class Artist(Base):
|
||||
label_collection: Collection[Label]
|
||||
|
||||
_default_factories = {
|
||||
"name": str,
|
||||
"unified_name": lambda: None,
|
||||
"name": lambda: None,
|
||||
"country": lambda: None,
|
||||
"unformatted_location": lambda: None,
|
||||
|
||||
@@ -457,17 +488,28 @@ class Artist(Base):
|
||||
TITEL = "name"
|
||||
|
||||
# This is automatically generated
|
||||
def __init__(self, name: str = "", unified_name: str = None, country: Country = None,
|
||||
formed_in: ID3Timestamp = None, notes: FormattedText = None, lyrical_themes: List[str] = None,
|
||||
general_genre: str = None, unformatted_location: str = None, source_list: List[Source] = None,
|
||||
contact_list: List[Contact] = None, feature_song_list: List[Song] = None,
|
||||
main_album_list: List[Album] = None, label_list: List[Label] = None, **kwargs) -> None:
|
||||
|
||||
super().__init__(name=name, unified_name=unified_name, country=country, formed_in=formed_in, notes=notes,
|
||||
lyrical_themes=lyrical_themes, general_genre=general_genre,
|
||||
unformatted_location=unformatted_location, source_list=source_list, contact_list=contact_list,
|
||||
feature_song_list=feature_song_list, main_album_list=main_album_list, label_list=label_list,
|
||||
**kwargs)
|
||||
def __init__(
|
||||
self,
|
||||
name: str = None,
|
||||
unified_name: str = None,
|
||||
country: Country = None,
|
||||
formed_in: ID3Timestamp = None,
|
||||
notes: FormattedText = None,
|
||||
lyrical_themes: List[str] = None,
|
||||
general_genre: str = None,
|
||||
unformatted_location: str = None,
|
||||
source_list: List[Source] = None,
|
||||
contact_list: List[Contact] = None,
|
||||
feature_song_list: List[Song] = None,
|
||||
main_album_list: List[Album] = None,
|
||||
label_list: List[Label] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
real_kwargs = copy.copy(locals())
|
||||
real_kwargs.update(real_kwargs.pop("kwargs", {}))
|
||||
|
||||
Base.__init__(**real_kwargs)
|
||||
|
||||
|
||||
DOWNWARDS_COLLECTION_STRING_ATTRIBUTES = ("main_album_collection", "feature_song_collection")
|
||||
UPWARDS_COLLECTION_STRING_ATTRIBUTES = ("label_collection",)
|
||||
@@ -502,6 +544,9 @@ class Artist(Base):
|
||||
self.label_collection.extend(object_list)
|
||||
return
|
||||
|
||||
def _compile(self):
|
||||
self.update_albumsort()
|
||||
|
||||
def update_albumsort(self):
|
||||
"""
|
||||
This updates the albumsort attributes, of the albums in
|
||||
@@ -512,9 +557,6 @@ class Artist(Base):
|
||||
|
||||
:return:
|
||||
"""
|
||||
if len(self.main_album_collection) <= 0:
|
||||
return
|
||||
|
||||
type_section: Dict[AlbumType, int] = defaultdict(lambda: 2, {
|
||||
AlbumType.OTHER: 0, # if I don't know it, I add it to the first section
|
||||
AlbumType.STUDIO_ALBUM: 0,
|
||||
@@ -557,7 +599,7 @@ class Artist(Base):
|
||||
album_list.extend(sections[section_index])
|
||||
|
||||
# replace the old collection with the new one
|
||||
self.main_album_collection: Collection = Collection(data=album_list, element_type=Album)
|
||||
self.main_album_collection._data = album_list
|
||||
|
||||
INDEX_DEPENDS_ON = ("name", "source_collection", "contact_collection")
|
||||
@property
|
||||
@@ -593,11 +635,6 @@ class Artist(Base):
|
||||
return r
|
||||
|
||||
|
||||
"""
|
||||
Label
|
||||
"""
|
||||
|
||||
|
||||
class Label(Base):
|
||||
COLLECTION_STRING_ATTRIBUTES = ("album_collection", "current_artist_collection")
|
||||
|
||||
@@ -625,12 +662,21 @@ class Label(Base):
|
||||
|
||||
TITEL = "name"
|
||||
|
||||
def __init__(self, name: str = None, unified_name: str = None, notes: FormattedText = None,
|
||||
source_list: List[Source] = None, contact_list: List[Contact] = None,
|
||||
album_list: List[Album] = None, current_artist_list: List[Artist] = None, **kwargs) -> None:
|
||||
super().__init__(name=name, unified_name=unified_name, notes=notes, source_list=source_list,
|
||||
contact_list=contact_list, album_list=album_list, current_artist_list=current_artist_list,
|
||||
**kwargs)
|
||||
def __init__(
|
||||
self,
|
||||
name: str = None,
|
||||
unified_name: str = None,
|
||||
notes: FormattedText = None,
|
||||
source_list: List[Source] = None,
|
||||
contact_list: List[Contact] = None,
|
||||
album_list: List[Album] = None,
|
||||
current_artist_list: List[Artist] = None,
|
||||
**kwargs
|
||||
) -> None:
|
||||
real_kwargs = copy.copy(locals())
|
||||
real_kwargs.update(real_kwargs.pop("kwargs", {}))
|
||||
|
||||
Base.__init__(**real_kwargs)
|
||||
|
||||
def __init_collections__(self):
|
||||
self.album_collection.append_object_to_attribute = {
|
||||
|
||||
@@ -8,7 +8,7 @@ from dataclasses import dataclass, field
|
||||
from functools import cached_property
|
||||
|
||||
from ..utils import generate_id
|
||||
from ..utils.enums.source import SourcePages, SourceTypes
|
||||
from ..utils.enums import SourceType
|
||||
from ..utils.config import youtube_settings
|
||||
from ..utils.string_processing import hash_url, shorten_display_url
|
||||
|
||||
@@ -20,22 +20,22 @@ from .collection import Collection
|
||||
|
||||
@dataclass
|
||||
class Source:
|
||||
page_enum: SourcePages
|
||||
source_type: SourceType
|
||||
url: str
|
||||
referrer_page: SourcePages = None
|
||||
referrer_page: SourceType = None
|
||||
audio_url: Optional[str] = None
|
||||
|
||||
additional_data: dict = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
self.referrer_page = self.referrer_page or self.page_enum
|
||||
self.referrer_page = self.referrer_page or self.source_type
|
||||
|
||||
@property
|
||||
def parsed_url(self) -> ParseResult:
|
||||
return urlparse(self.url)
|
||||
|
||||
@classmethod
|
||||
def match_url(cls, url: str, referrer_page: SourcePages) -> Optional[Source]:
|
||||
def match_url(cls, url: str, referrer_page: SourceType) -> Optional[Source]:
|
||||
"""
|
||||
this shouldn't be used, unless you are not certain what the source is for
|
||||
the reason is that it is more inefficient
|
||||
@@ -44,38 +44,38 @@ class Source:
|
||||
url = parsed_url.geturl()
|
||||
|
||||
if "musify" in parsed_url.netloc:
|
||||
return cls(SourcePages.MUSIFY, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.MUSIFY, url, referrer_page=referrer_page)
|
||||
|
||||
if parsed_url.netloc in [_url.netloc for _url in youtube_settings['youtube_url']]:
|
||||
return cls(SourcePages.YOUTUBE, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.YOUTUBE, url, referrer_page=referrer_page)
|
||||
|
||||
if url.startswith("https://www.deezer"):
|
||||
return cls(SourcePages.DEEZER, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.DEEZER, url, referrer_page=referrer_page)
|
||||
|
||||
if url.startswith("https://open.spotify.com"):
|
||||
return cls(SourcePages.SPOTIFY, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.SPOTIFY, url, referrer_page=referrer_page)
|
||||
|
||||
if "bandcamp" in url:
|
||||
return cls(SourcePages.BANDCAMP, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.BANDCAMP, url, referrer_page=referrer_page)
|
||||
|
||||
if "wikipedia" in parsed_url.netloc:
|
||||
return cls(SourcePages.WIKIPEDIA, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.WIKIPEDIA, url, referrer_page=referrer_page)
|
||||
|
||||
if url.startswith("https://www.metal-archives.com/"):
|
||||
return cls(SourcePages.ENCYCLOPAEDIA_METALLUM, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.ENCYCLOPAEDIA_METALLUM, url, referrer_page=referrer_page)
|
||||
|
||||
# the less important once
|
||||
if url.startswith("https://www.facebook"):
|
||||
return cls(SourcePages.FACEBOOK, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.FACEBOOK, url, referrer_page=referrer_page)
|
||||
|
||||
if url.startswith("https://www.instagram"):
|
||||
return cls(SourcePages.INSTAGRAM, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.INSTAGRAM, url, referrer_page=referrer_page)
|
||||
|
||||
if url.startswith("https://twitter"):
|
||||
return cls(SourcePages.TWITTER, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.TWITTER, url, referrer_page=referrer_page)
|
||||
|
||||
if url.startswith("https://myspace.com"):
|
||||
return cls(SourcePages.MYSPACE, url, referrer_page=referrer_page)
|
||||
return cls(SourceType.MYSPACE, url, referrer_page=referrer_page)
|
||||
|
||||
@property
|
||||
def hash_url(self) -> str:
|
||||
@@ -89,21 +89,21 @@ class Source:
|
||||
return r
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Src({self.page_enum.value}: {shorten_display_url(self.url)})"
|
||||
return f"Src({self.source_type.value}: {shorten_display_url(self.url)})"
|
||||
|
||||
def __merge__(self, other: Source, **kwargs):
|
||||
if self.audio_url is None:
|
||||
self.audio_url = other.audio_url
|
||||
self.additional_data.update(other.additional_data)
|
||||
|
||||
page_str = property(fget=lambda self: self.page_enum.value)
|
||||
page_str = property(fget=lambda self: self.source_type.value)
|
||||
|
||||
|
||||
class SourceCollection:
|
||||
__change_version__ = generate_id()
|
||||
|
||||
_indexed_sources: Dict[str, Source]
|
||||
_page_to_source_list: Dict[SourcePages, List[Source]]
|
||||
_page_to_source_list: Dict[SourceType, List[Source]]
|
||||
|
||||
def __init__(self, data: Optional[Iterable[Source]] = None, **kwargs):
|
||||
self._page_to_source_list = defaultdict(list)
|
||||
@@ -111,7 +111,7 @@ class SourceCollection:
|
||||
|
||||
self.extend(data or [])
|
||||
|
||||
def has_source_page(self, *source_pages: SourcePages) -> bool:
|
||||
def has_source_page(self, *source_pages: SourceType) -> bool:
|
||||
return any(source_page in self._page_to_source_list for source_page in source_pages)
|
||||
|
||||
def get_sources(self, *source_pages: List[Source]) -> Generator[Source]:
|
||||
@@ -135,7 +135,7 @@ class SourceCollection:
|
||||
existing_source.__merge__(source)
|
||||
source = existing_source
|
||||
else:
|
||||
self._page_to_source_list[source.page_enum].append(source)
|
||||
self._page_to_source_list[source.source_type].append(source)
|
||||
|
||||
changed = False
|
||||
for key in source.indexing_values:
|
||||
@@ -157,7 +157,7 @@ class SourceCollection:
|
||||
self.extend(other)
|
||||
|
||||
@property
|
||||
def source_pages(self) -> Iterable[SourcePages]:
|
||||
def source_pages(self) -> Iterable[SourceType]:
|
||||
return sorted(self._page_to_source_list.keys(), key=lambda page: page.value)
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, TextIO, Union
|
||||
from typing import List, Tuple, TextIO, Union, Optional
|
||||
import logging
|
||||
import random
|
||||
import requests
|
||||
@@ -31,7 +31,10 @@ class Target(OuterProxy):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def temp(cls, name: str = str(random.randint(0, HIGHEST_ID))) -> P:
|
||||
def temp(cls, name: str = str(random.randint(0, HIGHEST_ID)), file_extension: Optional[str] = None) -> P:
|
||||
if file_extension is not None:
|
||||
name = f"{name}.{file_extension}"
|
||||
|
||||
return cls(main_settings["temp_directory"] / name)
|
||||
|
||||
# This is automatically generated
|
||||
|
||||
@@ -3,8 +3,9 @@ import random
|
||||
import re
|
||||
from copy import copy
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, Type, Dict, Set, List, Tuple
|
||||
from typing import Optional, Union, Type, Dict, Set, List, Tuple, TypedDict
|
||||
from string import Formatter
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -21,85 +22,44 @@ from ..objects import (
|
||||
Collection,
|
||||
Label,
|
||||
)
|
||||
from ..utils.enums.source import SourcePages
|
||||
from ..utils.enums import SourceType
|
||||
from ..utils.enums.album import AlbumType
|
||||
from ..audio import write_metadata_to_target, correct_codec
|
||||
from ..utils.config import main_settings
|
||||
from ..utils.support_classes.query import Query
|
||||
from ..utils.support_classes.download_result import DownloadResult
|
||||
from ..utils.string_processing import fit_to_file_system
|
||||
from ..utils import trace
|
||||
from ..utils import trace, output, BColors
|
||||
|
||||
INDEPENDENT_DB_OBJECTS = Union[Label, Album, Artist, Song]
|
||||
INDEPENDENT_DB_TYPES = Union[Type[Song], Type[Album], Type[Artist], Type[Label]]
|
||||
|
||||
@dataclass
|
||||
class FetchOptions:
|
||||
download_all: bool = False
|
||||
album_type_blacklist: Set[AlbumType] = field(default_factory=lambda: set(AlbumType(a) for a in main_settings["album_type_blacklist"]))
|
||||
|
||||
class NamingDict(dict):
|
||||
CUSTOM_KEYS: Dict[str, str] = {
|
||||
"label": "label.name",
|
||||
"artist": "artist.name",
|
||||
"song": "song.title",
|
||||
"isrc": "song.isrc",
|
||||
"album": "album.title",
|
||||
"album_type": "album.album_type_string"
|
||||
}
|
||||
|
||||
def __init__(self, values: dict, object_mappings: Dict[str, DatabaseObject] = None):
|
||||
self.object_mappings: Dict[str, DatabaseObject] = object_mappings or dict()
|
||||
|
||||
super().__init__(values)
|
||||
self["audio_format"] = main_settings["audio_format"]
|
||||
|
||||
def add_object(self, music_object: DatabaseObject):
|
||||
self.object_mappings[type(music_object).__name__.lower()] = music_object
|
||||
|
||||
def copy(self) -> dict:
|
||||
return type(self)(super().copy(), self.object_mappings.copy())
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
return fit_to_file_system(super().__getitem__(key))
|
||||
|
||||
def default_value_for_name(self, name: str) -> str:
|
||||
return f'Various {name.replace("_", " ").title()}'
|
||||
|
||||
def __missing__(self, key: str) -> str:
|
||||
if "." not in key:
|
||||
if key not in self.CUSTOM_KEYS:
|
||||
return self.default_value_for_name(key)
|
||||
|
||||
key = self.CUSTOM_KEYS[key]
|
||||
|
||||
frag_list = key.split(".")
|
||||
|
||||
object_name = frag_list[0].strip().lower()
|
||||
attribute_name = frag_list[-1].strip().lower()
|
||||
|
||||
if object_name not in self.object_mappings:
|
||||
return self.default_value_for_name(attribute_name)
|
||||
|
||||
music_object = self.object_mappings[object_name]
|
||||
try:
|
||||
value = getattr(music_object, attribute_name)
|
||||
if value is None:
|
||||
return self.default_value_for_name(attribute_name)
|
||||
|
||||
return str(value)
|
||||
|
||||
except AttributeError:
|
||||
return self.default_value_for_name(attribute_name)
|
||||
@dataclass
|
||||
class DownloadOptions:
|
||||
download_all: bool = False
|
||||
album_type_blacklist: Set[AlbumType] = field(default_factory=lambda: set(AlbumType(a) for a in main_settings["album_type_blacklist"]))
|
||||
|
||||
process_audio_if_found: bool = False
|
||||
process_metadata_if_found: bool = True
|
||||
|
||||
class Page:
|
||||
"""
|
||||
This is an abstract class, laying out the
|
||||
functionality for every other class fetching something
|
||||
"""
|
||||
SOURCE_TYPE: SourceType
|
||||
LOGGER: LOGGER
|
||||
|
||||
SOURCE_TYPE: SourcePages
|
||||
LOGGER = logging.getLogger("this shouldn't be used")
|
||||
def __new__(cls, *args, **kwargs):
|
||||
cls.SOURCE_TYPE.register_page(cls)
|
||||
cls.LOGGER = logging.getLogger(cls.__name__)
|
||||
|
||||
# set this to true, if all song details can also be fetched by fetching album details
|
||||
NO_ADDITIONAL_DATA_FROM_SONG = False
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self, download_options: DownloadOptions = None, fetch_options: FetchOptions = None):
|
||||
self.download_options: DownloadOptions = download_options or DownloadOptions()
|
||||
self.fetch_options: FetchOptions = fetch_options or FetchOptions()
|
||||
|
||||
def _search_regex(self, pattern, string, default=None, fatal=True, flags=0, group=None):
|
||||
"""
|
||||
@@ -172,106 +132,7 @@ class Page:
|
||||
def song_search(self, song: Song) -> List[Song]:
|
||||
return []
|
||||
|
||||
def fetch_details(
|
||||
self,
|
||||
music_object: DatabaseObject,
|
||||
stop_at_level: int = 1,
|
||||
post_process: bool = True
|
||||
) -> DatabaseObject:
|
||||
"""
|
||||
when a music object with lacking data is passed in, it returns
|
||||
the SAME object **(no copy)** with more detailed data.
|
||||
If you for example put in, an album, it fetches the tracklist
|
||||
|
||||
:param music_object:
|
||||
:param stop_at_level:
|
||||
This says the depth of the level the scraper will recurse to.
|
||||
If this is for example set to 2, then the levels could be:
|
||||
1. Level: the album
|
||||
2. Level: every song of the album + every artist of the album
|
||||
If no additional requests are needed to get the data one level below the supposed stop level
|
||||
this gets ignored
|
||||
:return detailed_music_object: IT MODIFIES THE INPUT OBJ
|
||||
"""
|
||||
# creating a new object, of the same type
|
||||
new_music_object: Optional[DatabaseObject] = None
|
||||
fetched_from_url: List[str] = []
|
||||
|
||||
# only certain database objects, have a source list
|
||||
if isinstance(music_object, INDEPENDENT_DB_OBJECTS):
|
||||
source: Source
|
||||
for source in music_object.source_collection.get_sources(self.SOURCE_TYPE):
|
||||
if music_object.already_fetched_from(source.hash_url):
|
||||
continue
|
||||
|
||||
tmp = self.fetch_object_from_source(
|
||||
source=source,
|
||||
enforce_type=type(music_object),
|
||||
stop_at_level=stop_at_level,
|
||||
post_process=False,
|
||||
type_string=type(music_object).__name__,
|
||||
entity_string=music_object.option_string,
|
||||
)
|
||||
|
||||
if new_music_object is None:
|
||||
new_music_object = tmp
|
||||
else:
|
||||
new_music_object.merge(tmp)
|
||||
fetched_from_url.append(source.hash_url)
|
||||
|
||||
if new_music_object is not None:
|
||||
music_object.merge(new_music_object)
|
||||
|
||||
music_object.mark_as_fetched(*fetched_from_url)
|
||||
return music_object
|
||||
|
||||
def fetch_object_from_source(
|
||||
self,
|
||||
source: Source,
|
||||
stop_at_level: int = 2,
|
||||
enforce_type: Type[DatabaseObject] = None,
|
||||
post_process: bool = True,
|
||||
type_string: str = "",
|
||||
entity_string: str = "",
|
||||
) -> Optional[DatabaseObject]:
|
||||
|
||||
obj_type = self.get_source_type(source)
|
||||
|
||||
if obj_type is None:
|
||||
return None
|
||||
|
||||
if enforce_type != obj_type and enforce_type is not None:
|
||||
self.LOGGER.warning(f"Object type isn't type to enforce: {enforce_type}, {obj_type}")
|
||||
return None
|
||||
|
||||
music_object: DatabaseObject = None
|
||||
|
||||
fetch_map = {
|
||||
Song: self.fetch_song,
|
||||
Album: self.fetch_album,
|
||||
Artist: self.fetch_artist,
|
||||
Label: self.fetch_label
|
||||
}
|
||||
|
||||
if obj_type in fetch_map:
|
||||
music_object = fetch_map[obj_type](source, stop_at_level)
|
||||
else:
|
||||
self.LOGGER.warning(f"Can't fetch details of type: {obj_type}")
|
||||
return None
|
||||
|
||||
if stop_at_level > 0:
|
||||
trace(f"fetching {type_string} [{entity_string}] [stop_at_level={stop_at_level}]")
|
||||
|
||||
collection: Collection
|
||||
for collection_str in music_object.DOWNWARDS_COLLECTION_STRING_ATTRIBUTES:
|
||||
collection = music_object.__getattribute__(collection_str)
|
||||
|
||||
for sub_element in collection:
|
||||
sub_element.merge(
|
||||
self.fetch_details(sub_element, stop_at_level=stop_at_level - 1, post_process=False))
|
||||
|
||||
return music_object
|
||||
|
||||
# to fetch stuff
|
||||
def fetch_song(self, source: Source, stop_at_level: int = 1) -> Song:
|
||||
return Song()
|
||||
|
||||
@@ -288,8 +149,6 @@ class Page:
|
||||
self,
|
||||
music_object: DatabaseObject,
|
||||
genre: str,
|
||||
download_all: bool = False,
|
||||
process_metadata_anyway: bool = True
|
||||
) -> DownloadResult:
|
||||
naming_dict: NamingDict = NamingDict({"genre": genre})
|
||||
|
||||
@@ -308,25 +167,22 @@ class Page:
|
||||
|
||||
fill_naming_objects(music_object)
|
||||
|
||||
return self._download(music_object, naming_dict, download_all, process_metadata_anyway=process_metadata_anyway)
|
||||
return self._download(music_object, naming_dict)
|
||||
|
||||
def _download(
|
||||
self,
|
||||
music_object: DatabaseObject,
|
||||
naming_dict: NamingDict,
|
||||
download_all: bool = False,
|
||||
skip_details: bool = False,
|
||||
process_metadata_anyway: bool = True
|
||||
**kwargs
|
||||
) -> DownloadResult:
|
||||
trace(f"downloading {type(music_object).__name__} [{music_object.option_string}]")
|
||||
skip_next_details = skip_details
|
||||
if isinstance(music_object, Song):
|
||||
output(f"Downloading {music_object.option_string} to:", color=BColors.BOLD)
|
||||
else:
|
||||
output(f"Downloading {music_object.option_string}...", color=BColors.BOLD)
|
||||
|
||||
# Skips all releases, that are defined in shared.ALBUM_TYPE_BLACKLIST, if download_all is False
|
||||
if isinstance(music_object, Album):
|
||||
if self.NO_ADDITIONAL_DATA_FROM_SONG:
|
||||
skip_next_details = True
|
||||
|
||||
if not download_all and music_object.album_type.value in main_settings["album_type_blacklist"]:
|
||||
if not self.download_options.download_all and music_object.album_type in self.download_options.album_type_blacklist:
|
||||
return DownloadResult()
|
||||
|
||||
if not (isinstance(music_object, Song) and self.NO_ADDITIONAL_DATA_FROM_SONG):
|
||||
@@ -338,7 +194,7 @@ class Page:
|
||||
naming_dict.add_object(music_object)
|
||||
|
||||
if isinstance(music_object, Song):
|
||||
return self._download_song(music_object, naming_dict, process_metadata_anyway=process_metadata_anyway)
|
||||
return self._download_song(music_object, naming_dict)
|
||||
|
||||
download_result: DownloadResult = DownloadResult()
|
||||
|
||||
@@ -347,13 +203,12 @@ class Page:
|
||||
|
||||
sub_ordered_music_object: DatabaseObject
|
||||
for sub_ordered_music_object in collection:
|
||||
download_result.merge(self._download(sub_ordered_music_object, naming_dict.copy(), download_all,
|
||||
skip_details=skip_next_details,
|
||||
process_metadata_anyway=process_metadata_anyway))
|
||||
download_result.merge(self._download(sub_ordered_music_object, naming_dict.copy()))
|
||||
|
||||
return download_result
|
||||
|
||||
def _download_song(self, song: Song, naming_dict: NamingDict, process_metadata_anyway: bool = True):
|
||||
def _download_song(self, song: Song, naming_dict: NamingDict):
|
||||
song.compile()
|
||||
if "genre" not in naming_dict and song.genre is not None:
|
||||
naming_dict["genre"] = song.genre
|
||||
|
||||
@@ -373,58 +228,56 @@ class Page:
|
||||
if song.target_collection.empty:
|
||||
song.target_collection.append(new_target)
|
||||
|
||||
r = DownloadResult(1)
|
||||
temp_target: Target = Target.temp(file_extension=main_settings["audio_format"])
|
||||
|
||||
found_on_disc = False
|
||||
target: Target
|
||||
for target in song.target_collection:
|
||||
current_exists = target.exists
|
||||
|
||||
if current_exists:
|
||||
output(f'- {target.file_path} {BColors.OKGREEN.value}[already exists]', color=BColors.GREY)
|
||||
target.copy_content(temp_target)
|
||||
found_on_disc = True
|
||||
|
||||
r.found_on_disk += 1
|
||||
r.add_target(target)
|
||||
else:
|
||||
output(f'- {target.file_path}', color=BColors.GREY)
|
||||
|
||||
if not song.source_collection.has_source_page(self.SOURCE_TYPE):
|
||||
return DownloadResult(error_message=f"No {self.__class__.__name__} source found for {song.option_string}.")
|
||||
|
||||
sources = song.source_collection.get_sources(self.SOURCE_TYPE)
|
||||
|
||||
temp_target: Target = Target(
|
||||
relative_to_music_dir=False,
|
||||
file_path=Path(
|
||||
main_settings["temp_directory"],
|
||||
str(song.id)
|
||||
)
|
||||
)
|
||||
|
||||
r = DownloadResult(1)
|
||||
|
||||
found_on_disc = False
|
||||
target: Target
|
||||
for target in song.target_collection:
|
||||
if target.exists:
|
||||
if process_metadata_anyway:
|
||||
target.copy_content(temp_target)
|
||||
found_on_disc = True
|
||||
|
||||
r.found_on_disk += 1
|
||||
r.add_target(target)
|
||||
|
||||
if found_on_disc and not process_metadata_anyway:
|
||||
self.LOGGER.info(f"{song.option_string} already exists, thus not downloading again.")
|
||||
return r
|
||||
|
||||
skip_intervals = []
|
||||
if not found_on_disc:
|
||||
for source in sources:
|
||||
r = self.download_song_to_target(source=source, target=temp_target, desc=song.option_string)
|
||||
r = self.download_song_to_target(source=source, target=temp_target, desc="downloading")
|
||||
|
||||
if not r.is_fatal_error:
|
||||
skip_intervals = self.get_skip_intervals(song, source)
|
||||
break
|
||||
|
||||
if temp_target.exists:
|
||||
r.merge(self._post_process_targets(
|
||||
song=song,
|
||||
temp_target=temp_target,
|
||||
interval_list=[] if found_on_disc else self.get_skip_intervals(song, source)
|
||||
interval_list=skip_intervals,
|
||||
found_on_disc=found_on_disc,
|
||||
))
|
||||
|
||||
return r
|
||||
|
||||
def _post_process_targets(self, song: Song, temp_target: Target, interval_list: List) -> DownloadResult:
|
||||
correct_codec(temp_target, interval_list=interval_list)
|
||||
def _post_process_targets(self, song: Song, temp_target: Target, interval_list: List, found_on_disc: bool) -> DownloadResult:
|
||||
if not found_on_disc or self.download_options.process_audio_if_found:
|
||||
correct_codec(temp_target, interval_list=interval_list)
|
||||
|
||||
self.post_process_hook(song, temp_target)
|
||||
|
||||
write_metadata_to_target(song.metadata, temp_target, song)
|
||||
if not found_on_disc or self.download_options.process_metadata_if_found:
|
||||
write_metadata_to_target(song.metadata, temp_target, song)
|
||||
|
||||
r = DownloadResult()
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from .abstract import Page
|
||||
from ..objects import (
|
||||
Artist,
|
||||
Source,
|
||||
SourcePages,
|
||||
SourceType,
|
||||
Song,
|
||||
Album,
|
||||
Label,
|
||||
@@ -22,6 +22,8 @@ from ..objects import (
|
||||
Artwork,
|
||||
)
|
||||
from ..connection import Connection
|
||||
from ..utils import dump_to_file
|
||||
from ..utils.enums import SourceType, ALL_SOURCE_TYPES
|
||||
from ..utils.support_classes.download_result import DownloadResult
|
||||
from ..utils.string_processing import clean_song_title
|
||||
from ..utils.config import main_settings, logging_settings
|
||||
@@ -48,8 +50,7 @@ class BandcampTypes(Enum):
|
||||
|
||||
|
||||
class Bandcamp(Page):
|
||||
# CHANGE
|
||||
SOURCE_TYPE = SourcePages.BANDCAMP
|
||||
SOURCE_TYPE = ALL_SOURCE_TYPES.BANDCAMP
|
||||
LOGGER = logging_settings["bandcamp_logger"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -7,7 +7,7 @@ from urllib.parse import urlparse, urlencode
|
||||
from ..connection import Connection
|
||||
from ..utils.config import logging_settings
|
||||
from .abstract import Page
|
||||
from ..utils.enums.source import SourcePages
|
||||
from ..utils.enums.source import SourceType
|
||||
from ..utils.enums.album import AlbumType
|
||||
from ..utils.support_classes.query import Query
|
||||
from ..objects import (
|
||||
@@ -59,7 +59,7 @@ def _song_from_json(artist_html=None, album_html=None, release_type=None, title=
|
||||
_album_from_json(album_html=album_html, release_type=release_type, artist_html=artist_html)
|
||||
],
|
||||
source_list=[
|
||||
Source(SourcePages.ENCYCLOPAEDIA_METALLUM, song_id)
|
||||
Source(SourceType.ENCYCLOPAEDIA_METALLUM, song_id)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ def _artist_from_json(artist_html=None, genre=None, country=None) -> Artist:
|
||||
return Artist(
|
||||
name=artist_name,
|
||||
source_list=[
|
||||
Source(SourcePages.ENCYCLOPAEDIA_METALLUM, artist_url)
|
||||
Source(SourceType.ENCYCLOPAEDIA_METALLUM, artist_url)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ def _album_from_json(album_html=None, release_type=None, artist_html=None) -> Al
|
||||
title=album_name,
|
||||
album_type=album_type,
|
||||
source_list=[
|
||||
Source(SourcePages.ENCYCLOPAEDIA_METALLUM, album_url)
|
||||
Source(SourceType.ENCYCLOPAEDIA_METALLUM, album_url)
|
||||
],
|
||||
artist_list=[
|
||||
_artist_from_json(artist_html=artist_html)
|
||||
@@ -207,7 +207,7 @@ def create_grid(
|
||||
|
||||
|
||||
class EncyclopaediaMetallum(Page):
|
||||
SOURCE_TYPE = SourcePages.ENCYCLOPAEDIA_METALLUM
|
||||
SOURCE_TYPE = SourceType.ENCYCLOPAEDIA_METALLUM
|
||||
LOGGER = logging_settings["metal_archives_logger"]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -832,7 +832,7 @@ class EncyclopaediaMetallum(Page):
|
||||
)
|
||||
|
||||
def get_source_type(self, source: Source):
|
||||
if self.SOURCE_TYPE != source.page_enum:
|
||||
if self.SOURCE_TYPE != source.source_type:
|
||||
return None
|
||||
|
||||
url = source.url
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Type, Union, Generator
|
||||
from typing import List, Optional, Type, Union, Generator, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pycountry
|
||||
@@ -9,7 +9,7 @@ from bs4 import BeautifulSoup
|
||||
|
||||
from ..connection import Connection
|
||||
from .abstract import Page
|
||||
from ..utils.enums.source import SourcePages
|
||||
from ..utils.enums import SourceType, ALL_SOURCE_TYPES
|
||||
from ..utils.enums.album import AlbumType, AlbumStatus
|
||||
from ..objects import (
|
||||
Artist,
|
||||
@@ -24,7 +24,7 @@ from ..objects import (
|
||||
Lyrics,
|
||||
Artwork
|
||||
)
|
||||
from ..utils.config import logging_settings
|
||||
from ..utils.config import logging_settings, main_settings
|
||||
from ..utils import string_processing, shared
|
||||
from ..utils.string_processing import clean_song_title
|
||||
from ..utils.support_classes.query import Query
|
||||
@@ -111,9 +111,7 @@ def parse_url(url: str) -> MusifyUrl:
|
||||
|
||||
|
||||
class Musify(Page):
|
||||
# CHANGE
|
||||
SOURCE_TYPE = SourcePages.MUSIFY
|
||||
LOGGER = logging_settings["musify_logger"]
|
||||
SOURCE_TYPE = ALL_SOURCE_TYPES.MUSIFY
|
||||
|
||||
HOST = "https://musify.club"
|
||||
|
||||
@@ -361,7 +359,7 @@ class Musify(Page):
|
||||
|
||||
return Song(
|
||||
title=clean_song_title(song_title, artist_name=artist_list[0].name if len(artist_list) > 0 else None),
|
||||
main_artist_list=artist_list,
|
||||
feature_artist_list=artist_list,
|
||||
source_list=source_list
|
||||
)
|
||||
|
||||
@@ -418,6 +416,10 @@ class Musify(Page):
|
||||
|
||||
href = artist_soup["href"]
|
||||
if href is not None:
|
||||
href_parts = href.split("/")
|
||||
if len(href_parts) <= 1 or href_parts[-2] != "artist":
|
||||
return
|
||||
|
||||
artist_src_list.append(Source(self.SOURCE_TYPE, self.HOST + href))
|
||||
|
||||
name_elem: BeautifulSoup = artist_soup.find("span", {"itemprop": "name"})
|
||||
@@ -501,7 +503,7 @@ class Musify(Page):
|
||||
iframe_list: List[BeautifulSoup] = video_container.findAll("iframe")
|
||||
for iframe in iframe_list:
|
||||
source_list.append(Source(
|
||||
SourcePages.YOUTUBE,
|
||||
SourceType.YOUTUBE,
|
||||
iframe["src"],
|
||||
referrer_page=self.SOURCE_TYPE
|
||||
))
|
||||
@@ -510,7 +512,7 @@ class Musify(Page):
|
||||
title=clean_song_title(track_name, artist_name=artist_list[0].name if len(artist_list) > 0 else None),
|
||||
source_list=source_list,
|
||||
lyrics_list=lyrics_list,
|
||||
main_artist_list=artist_list,
|
||||
feature_artist_list=artist_list,
|
||||
album_list=album_list,
|
||||
artwork=artwork,
|
||||
)
|
||||
@@ -652,10 +654,104 @@ class Musify(Page):
|
||||
return Song(
|
||||
title=clean_song_title(song_name, artist_name=artist_list[0].name if len(artist_list) > 0 else None),
|
||||
tracksort=tracksort,
|
||||
main_artist_list=artist_list,
|
||||
feature_artist_list=artist_list,
|
||||
source_list=source_list
|
||||
)
|
||||
|
||||
|
||||
def _parse_album(self, soup: BeautifulSoup) -> Album:
|
||||
name: str = None
|
||||
source_list: List[Source] = []
|
||||
artist_list: List[Artist] = []
|
||||
date: ID3Timestamp = None
|
||||
|
||||
"""
|
||||
if breadcrumb list has 4 elements, then
|
||||
the -2 is the artist link,
|
||||
the -1 is the album
|
||||
"""
|
||||
# breadcrumb
|
||||
breadcrumb_soup: BeautifulSoup = soup.find("ol", {"class", "breadcrumb"})
|
||||
breadcrumb_elements: List[BeautifulSoup] = breadcrumb_soup.find_all("li", {"class": "breadcrumb-item"})
|
||||
if len(breadcrumb_elements) == 4:
|
||||
# album
|
||||
album_crumb: BeautifulSoup = breadcrumb_elements[-1]
|
||||
name = album_crumb.text.strip()
|
||||
|
||||
# artist
|
||||
artist_crumb: BeautifulSoup = breadcrumb_elements[-2]
|
||||
anchor: BeautifulSoup = artist_crumb.find("a")
|
||||
if anchor is not None:
|
||||
href = anchor.get("href")
|
||||
|
||||
href_parts = href.split("/")
|
||||
if not(len(href_parts) <= 1 or href_parts[-2] != "artist"):
|
||||
artist_source_list: List[Source] = []
|
||||
|
||||
if href is not None:
|
||||
artist_source_list.append(Source(self.SOURCE_TYPE, self.HOST + href.strip()))
|
||||
|
||||
span: BeautifulSoup = anchor.find("span")
|
||||
if span is not None:
|
||||
artist_list.append(Artist(
|
||||
name=span.get_text(strip=True),
|
||||
source_list=artist_source_list
|
||||
))
|
||||
else:
|
||||
self.LOGGER.debug("there are not 4 breadcrumb items, which shouldn't be the case")
|
||||
|
||||
# meta
|
||||
meta_url: BeautifulSoup = soup.find("meta", {"itemprop": "url"})
|
||||
if meta_url is not None:
|
||||
url = meta_url.get("content")
|
||||
if url is not None:
|
||||
source_list.append(Source(self.SOURCE_TYPE, self.HOST + url))
|
||||
|
||||
meta_name: BeautifulSoup = soup.find("meta", {"itemprop": "name"})
|
||||
if meta_name is not None:
|
||||
_name = meta_name.get("content")
|
||||
if _name is not None:
|
||||
name = _name
|
||||
|
||||
# album info
|
||||
album_info_ul: BeautifulSoup = soup.find("ul", {"class": "album-info"})
|
||||
if album_info_ul is not None:
|
||||
artist_anchor: BeautifulSoup
|
||||
for artist_anchor in album_info_ul.find_all("a", {"itemprop": "byArtist"}):
|
||||
# line 98
|
||||
artist_source_list: List[Source] = []
|
||||
|
||||
artist_url_meta = artist_anchor.find("meta", {"itemprop": "url"})
|
||||
if artist_url_meta is not None:
|
||||
artist_href = artist_url_meta.get("content")
|
||||
if artist_href is not None:
|
||||
artist_source_list.append(Source(self.SOURCE_TYPE, url=self.HOST + artist_href))
|
||||
|
||||
artist_meta_name = artist_anchor.find("meta", {"itemprop": "name"})
|
||||
if artist_meta_name is not None:
|
||||
artist_name = artist_meta_name.get("content")
|
||||
if artist_name is not None:
|
||||
artist_list.append(Artist(
|
||||
name=artist_name,
|
||||
source_list=artist_source_list
|
||||
))
|
||||
|
||||
time_soup: BeautifulSoup = album_info_ul.find("time", {"itemprop": "datePublished"})
|
||||
if time_soup is not None:
|
||||
raw_datetime = time_soup.get("datetime")
|
||||
if raw_datetime is not None:
|
||||
try:
|
||||
date = ID3Timestamp.strptime(raw_datetime, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
self.LOGGER.debug(f"Raw datetime doesn't match time format %Y-%m-%d: {raw_datetime}")
|
||||
|
||||
return Album(
|
||||
title=name,
|
||||
source_list=source_list,
|
||||
artist_list=artist_list,
|
||||
date=date
|
||||
)
|
||||
|
||||
def fetch_album(self, source: Source, stop_at_level: int = 1) -> Album:
|
||||
"""
|
||||
fetches album from source:
|
||||
@@ -694,19 +790,14 @@ class Musify(Page):
|
||||
|
||||
return album
|
||||
|
||||
def _get_artist_attributes(self, url: MusifyUrl) -> Artist:
|
||||
def _fetch_initial_artist(self, url: MusifyUrl, source: Source, **kwargs) -> Artist:
|
||||
"""
|
||||
fetches the main Artist attributes from this endpoint
|
||||
https://musify.club/artist/ghost-bath-280348?_pjax=#bodyContent
|
||||
it needs to parse html
|
||||
|
||||
:param url:
|
||||
:return:
|
||||
"""
|
||||
|
||||
r = self.connection.get(f"https://musify.club/{url.source_type.value}/{url.name_with_id}?_pjax=#bodyContent", name="artist_attributes_" + url.name_with_id)
|
||||
if r is None:
|
||||
return Artist()
|
||||
return Artist(source_list=[source])
|
||||
|
||||
soup = self.get_soup_from_response(r)
|
||||
|
||||
@@ -821,7 +912,7 @@ class Musify(Page):
|
||||
notes=notes
|
||||
)
|
||||
|
||||
def _parse_album_card(self, album_card: BeautifulSoup, artist_name: str = None) -> Album:
|
||||
def _parse_album_card(self, album_card: BeautifulSoup, artist_name: str = None, **kwargs) -> Album:
|
||||
"""
|
||||
<div class="card release-thumbnail" data-type="2">
|
||||
<a href="/release/ghost-bath-self-loather-2021-1554266">
|
||||
@@ -845,46 +936,20 @@ class Musify(Page):
|
||||
</div>
|
||||
"""
|
||||
|
||||
_id: Optional[str] = None
|
||||
name: str = None
|
||||
source_list: List[Source] = []
|
||||
timestamp: Optional[ID3Timestamp] = None
|
||||
album_status = None
|
||||
|
||||
def set_name(new_name: str):
|
||||
nonlocal name
|
||||
nonlocal artist_name
|
||||
|
||||
# example of just setting not working:
|
||||
# https://musify.club/release/unjoy-eurythmie-psychonaut-4-tired-numb-still-alive-2012-324067
|
||||
if new_name.count(" - ") != 1:
|
||||
name = new_name
|
||||
return
|
||||
|
||||
potential_artist_list, potential_name = new_name.split(" - ")
|
||||
unified_artist_list = string_processing.unify(potential_artist_list)
|
||||
if artist_name is not None:
|
||||
if string_processing.unify(artist_name) not in unified_artist_list:
|
||||
name = new_name
|
||||
return
|
||||
|
||||
name = potential_name
|
||||
return
|
||||
|
||||
name = new_name
|
||||
album_kwargs: Dict[str, Any] = {
|
||||
"source_list": [],
|
||||
}
|
||||
|
||||
album_status_id = album_card.get("data-type")
|
||||
if album_status_id.isdigit():
|
||||
album_status_id = int(album_status_id)
|
||||
album_type = ALBUM_TYPE_MAP[album_status_id]
|
||||
album_kwargs["album_type"] = ALBUM_TYPE_MAP[album_status_id]
|
||||
|
||||
if album_status_id == 5:
|
||||
album_status = AlbumStatus.BOOTLEG
|
||||
album_kwargs["album_status"] = AlbumStatus.BOOTLEG
|
||||
|
||||
def parse_release_anchor(_anchor: BeautifulSoup, text_is_name=False):
|
||||
nonlocal _id
|
||||
nonlocal name
|
||||
nonlocal source_list
|
||||
nonlocal album_kwargs
|
||||
|
||||
if _anchor is None:
|
||||
return
|
||||
@@ -892,20 +957,13 @@ class Musify(Page):
|
||||
href = _anchor.get("href")
|
||||
if href is not None:
|
||||
# add url to sources
|
||||
source_list.append(Source(
|
||||
album_kwargs["source_list"].append(Source(
|
||||
self.SOURCE_TYPE,
|
||||
self.HOST + href
|
||||
))
|
||||
|
||||
# split id from url
|
||||
split_href = href.split("-")
|
||||
if len(split_href) > 1:
|
||||
_id = split_href[-1]
|
||||
|
||||
if not text_is_name:
|
||||
return
|
||||
|
||||
set_name(_anchor.text)
|
||||
if text_is_name:
|
||||
album_kwargs["title"] = clean_song_title(_anchor.text, artist_name)
|
||||
|
||||
anchor_list = album_card.find_all("a", recursive=False)
|
||||
if len(anchor_list) > 0:
|
||||
@@ -916,7 +974,7 @@ class Musify(Page):
|
||||
if thumbnail is not None:
|
||||
alt = thumbnail.get("alt")
|
||||
if alt is not None:
|
||||
set_name(alt)
|
||||
album_kwargs["title"] = clean_song_title(alt, artist_name)
|
||||
|
||||
image_url = thumbnail.get("src")
|
||||
else:
|
||||
@@ -933,7 +991,7 @@ class Musify(Page):
|
||||
13.11.2021
|
||||
</small>
|
||||
"""
|
||||
nonlocal timestamp
|
||||
nonlocal album_kwargs
|
||||
|
||||
italic_tagging_soup: BeautifulSoup = small_soup.find("i")
|
||||
if italic_tagging_soup is None:
|
||||
@@ -943,7 +1001,7 @@ class Musify(Page):
|
||||
return
|
||||
|
||||
raw_time = small_soup.text.strip()
|
||||
timestamp = ID3Timestamp.strptime(raw_time, "%d.%m.%Y")
|
||||
album_kwargs["date"] = ID3Timestamp.strptime(raw_time, "%d.%m.%Y")
|
||||
|
||||
# parse small date
|
||||
card_footer_list = album_card.find_all("div", {"class": "card-footer"})
|
||||
@@ -956,112 +1014,18 @@ class Musify(Page):
|
||||
else:
|
||||
self.LOGGER.debug("there is not even 1 footer in the album card")
|
||||
|
||||
return Album(
|
||||
title=name,
|
||||
source_list=source_list,
|
||||
date=timestamp,
|
||||
album_type=album_type,
|
||||
album_status=album_status
|
||||
)
|
||||
return Album(**album_kwargs)
|
||||
|
||||
def _parse_album(self, soup: BeautifulSoup) -> Album:
|
||||
name: str = None
|
||||
source_list: List[Source] = []
|
||||
artist_list: List[Artist] = []
|
||||
date: ID3Timestamp = None
|
||||
|
||||
"""
|
||||
if breadcrumb list has 4 elements, then
|
||||
the -2 is the artist link,
|
||||
the -1 is the album
|
||||
"""
|
||||
# breadcrumb
|
||||
breadcrumb_soup: BeautifulSoup = soup.find("ol", {"class", "breadcrumb"})
|
||||
breadcrumb_elements: List[BeautifulSoup] = breadcrumb_soup.find_all("li", {"class": "breadcrumb-item"})
|
||||
if len(breadcrumb_elements) == 4:
|
||||
# album
|
||||
album_crumb: BeautifulSoup = breadcrumb_elements[-1]
|
||||
name = album_crumb.text.strip()
|
||||
|
||||
# artist
|
||||
artist_crumb: BeautifulSoup = breadcrumb_elements[-2]
|
||||
anchor: BeautifulSoup = artist_crumb.find("a")
|
||||
if anchor is not None:
|
||||
href = anchor.get("href")
|
||||
artist_source_list: List[Source] = []
|
||||
|
||||
if href is not None:
|
||||
artist_source_list.append(Source(self.SOURCE_TYPE, self.HOST + href.strip()))
|
||||
|
||||
span: BeautifulSoup = anchor.find("span")
|
||||
if span is not None:
|
||||
artist_list.append(Artist(
|
||||
name=span.get_text(strip=True),
|
||||
source_list=artist_source_list
|
||||
))
|
||||
else:
|
||||
self.LOGGER.debug("there are not 4 breadcrumb items, which shouldn't be the case")
|
||||
|
||||
# meta
|
||||
meta_url: BeautifulSoup = soup.find("meta", {"itemprop": "url"})
|
||||
if meta_url is not None:
|
||||
url = meta_url.get("content")
|
||||
if url is not None:
|
||||
source_list.append(Source(self.SOURCE_TYPE, self.HOST + url))
|
||||
|
||||
meta_name: BeautifulSoup = soup.find("meta", {"itemprop": "name"})
|
||||
if meta_name is not None:
|
||||
_name = meta_name.get("content")
|
||||
if _name is not None:
|
||||
name = _name
|
||||
|
||||
# album info
|
||||
album_info_ul: BeautifulSoup = soup.find("ul", {"class": "album-info"})
|
||||
if album_info_ul is not None:
|
||||
artist_anchor: BeautifulSoup
|
||||
for artist_anchor in album_info_ul.find_all("a", {"itemprop": "byArtist"}):
|
||||
# line 98
|
||||
artist_source_list: List[Source] = []
|
||||
|
||||
artist_url_meta = artist_anchor.find("meta", {"itemprop": "url"})
|
||||
if artist_url_meta is not None:
|
||||
artist_href = artist_url_meta.get("content")
|
||||
if artist_href is not None:
|
||||
artist_source_list.append(Source(self.SOURCE_TYPE, url=self.HOST + artist_href))
|
||||
|
||||
artist_meta_name = artist_anchor.find("meta", {"itemprop": "name"})
|
||||
if artist_meta_name is not None:
|
||||
artist_name = artist_meta_name.get("content")
|
||||
if artist_name is not None:
|
||||
artist_list.append(Artist(
|
||||
name=artist_name,
|
||||
source_list=artist_source_list
|
||||
))
|
||||
|
||||
time_soup: BeautifulSoup = album_info_ul.find("time", {"itemprop": "datePublished"})
|
||||
if time_soup is not None:
|
||||
raw_datetime = time_soup.get("datetime")
|
||||
if raw_datetime is not None:
|
||||
try:
|
||||
date = ID3Timestamp.strptime(raw_datetime, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
self.LOGGER.debug(f"Raw datetime doesn't match time format %Y-%m-%d: {raw_datetime}")
|
||||
|
||||
return Album(
|
||||
title=name,
|
||||
source_list=source_list,
|
||||
artist_list=artist_list,
|
||||
date=date
|
||||
)
|
||||
|
||||
def _get_discography(self, url: MusifyUrl, artist_name: str = None, stop_at_level: int = 1) -> Generator[Album, None, None]:
|
||||
def _fetch_artist_discography(self, artist: Artist, url: MusifyUrl, artist_name: str = None, **kwargs):
|
||||
"""
|
||||
POST https://musify.club/artist/filteralbums
|
||||
ArtistID: 280348
|
||||
SortOrder.Property: dateCreated
|
||||
SortOrder.IsAscending: false
|
||||
X-Requested-With: XMLHttpRequest
|
||||
ArtistID: 280348
|
||||
SortOrder.Property: dateCreated
|
||||
SortOrder.IsAscending: false
|
||||
X-Requested-With: XMLHttpRequest
|
||||
"""
|
||||
_download_all = kwargs.get("download_all", False)
|
||||
_album_type_blacklist = kwargs.get("album_type_blacklist", main_settings["album_type_blacklist"])
|
||||
|
||||
endpoint = self.HOST + "/" + url.source_type.value + "/filteralbums"
|
||||
|
||||
@@ -1072,33 +1036,29 @@ class Musify(Page):
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
}, name="discography_" + url.name_with_id)
|
||||
if r is None:
|
||||
return []
|
||||
soup: BeautifulSoup = BeautifulSoup(r.content, features="html.parser")
|
||||
return
|
||||
|
||||
soup: BeautifulSoup = self.get_soup_from_response(r)
|
||||
|
||||
for card_soup in soup.find_all("div", {"class": "card"}):
|
||||
yield self._parse_album_card(card_soup, artist_name)
|
||||
album = self._parse_album_card(card_soup, artist_name, **kwargs)
|
||||
if not self.fetch_options.download_all and album.album_type in self.fetch_options.album_type_blacklist:
|
||||
continue
|
||||
|
||||
def fetch_artist(self, source: Source, stop_at_level: int = 1) -> Artist:
|
||||
artist.main_album_collection.append(album)
|
||||
|
||||
def fetch_artist(self, source: Source, **kwargs) -> Artist:
|
||||
"""
|
||||
fetches artist from source
|
||||
|
||||
TODO
|
||||
[x] discography
|
||||
[x] attributes
|
||||
[] picture gallery
|
||||
|
||||
Args:
|
||||
source (Source): the source to fetch
|
||||
stop_at_level: int = 1: if it is false, every album from discograohy will be fetched. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Artist: the artist fetched
|
||||
"""
|
||||
|
||||
url = parse_url(source.url)
|
||||
|
||||
artist = self._get_artist_attributes(url)
|
||||
|
||||
artist.main_album_collection.extend(self._get_discography(url, artist.name))
|
||||
artist = self._fetch_initial_artist(url, source=source, **kwargs)
|
||||
self._fetch_artist_discography(artist, url, artist.name, **kwargs)
|
||||
|
||||
return artist
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
from typing import List, Optional, Type
|
||||
from urllib.parse import urlparse
|
||||
import logging
|
||||
|
||||
|
||||
from ..objects import Source, DatabaseObject
|
||||
from .abstract import Page
|
||||
from ..objects import (
|
||||
Artist,
|
||||
Source,
|
||||
SourcePages,
|
||||
Song,
|
||||
Album,
|
||||
Label,
|
||||
Target
|
||||
)
|
||||
from ..connection import Connection
|
||||
from ..utils.support_classes.query import Query
|
||||
from ..utils.support_classes.download_result import DownloadResult
|
||||
|
||||
class Preset(Page):
|
||||
# CHANGE
|
||||
SOURCE_TYPE = SourcePages.PRESET
|
||||
LOGGER = logging.getLogger("preset")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.connection: Connection = Connection(
|
||||
host="https://www.preset.cum/",
|
||||
logger=self.LOGGER
|
||||
)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_source_type(self, source: Source) -> Optional[Type[DatabaseObject]]:
|
||||
return super().get_source_type(source)
|
||||
|
||||
def general_search(self, search_query: str) -> List[DatabaseObject]:
|
||||
return []
|
||||
|
||||
def label_search(self, label: Label) -> List[Label]:
|
||||
return []
|
||||
|
||||
def artist_search(self, artist: Artist) -> List[Artist]:
|
||||
return []
|
||||
|
||||
def album_search(self, album: Album) -> List[Album]:
|
||||
return []
|
||||
|
||||
def song_search(self, song: Song) -> List[Song]:
|
||||
return []
|
||||
|
||||
def fetch_song(self, source: Source, stop_at_level: int = 1) -> Song:
|
||||
return Song()
|
||||
|
||||
def fetch_album(self, source: Source, stop_at_level: int = 1) -> Album:
|
||||
return Album()
|
||||
|
||||
def fetch_artist(self, source: Source, stop_at_level: int = 1) -> Artist:
|
||||
return Artist()
|
||||
|
||||
def fetch_label(self, source: Source, stop_at_level: int = 1) -> Label:
|
||||
return Label()
|
||||
|
||||
def download_song_to_target(self, source: Source, target: Target, desc: str = None) -> DownloadResult:
|
||||
return DownloadResult()
|
||||
@@ -9,7 +9,6 @@ from .abstract import Page
|
||||
from ..objects import (
|
||||
Artist,
|
||||
Source,
|
||||
SourcePages,
|
||||
Song,
|
||||
Album,
|
||||
Label,
|
||||
@@ -19,6 +18,7 @@ from ..objects import (
|
||||
)
|
||||
from ..connection import Connection
|
||||
from ..utils.string_processing import clean_song_title
|
||||
from ..utils.enums import SourceType, ALL_SOURCE_TYPES
|
||||
from ..utils.support_classes.download_result import DownloadResult
|
||||
from ..utils.config import youtube_settings, main_settings, logging_settings
|
||||
|
||||
@@ -39,10 +39,9 @@ def get_piped_url(path: str = "", params: str = "", query: str = "", fragment: s
|
||||
|
||||
class YouTube(SuperYouTube):
|
||||
# CHANGE
|
||||
SOURCE_TYPE = SourcePages.YOUTUBE
|
||||
LOGGER = logging_settings["youtube_logger"]
|
||||
SOURCE_TYPE = ALL_SOURCE_TYPES.YOUTUBE
|
||||
|
||||
NO_ADDITIONAL_DATA_FROM_SONG = True
|
||||
NO_ADDITIONAL_DATA_FROM_SONG = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.connection: Connection = Connection(
|
||||
|
||||
@@ -7,7 +7,6 @@ from ..abstract import Page
|
||||
from ...objects import (
|
||||
Artist,
|
||||
Source,
|
||||
SourcePages,
|
||||
Song,
|
||||
Album,
|
||||
Label,
|
||||
|
||||
@@ -3,12 +3,13 @@ from enum import Enum
|
||||
|
||||
from ...utils.config import youtube_settings, logging_settings
|
||||
from ...utils.string_processing import clean_song_title
|
||||
from ...utils.enums import SourceType, ALL_SOURCE_TYPES
|
||||
|
||||
from ...objects import Source, DatabaseObject
|
||||
from ..abstract import Page
|
||||
from ...objects import (
|
||||
Artist,
|
||||
Source,
|
||||
SourcePages,
|
||||
Song,
|
||||
Album,
|
||||
Label,
|
||||
@@ -18,7 +19,7 @@ from ...objects import (
|
||||
LOGGER = logging_settings["youtube_music_logger"]
|
||||
|
||||
|
||||
SOURCE_PAGE = SourcePages.YOUTUBE_MUSIC
|
||||
SOURCE_PAGE = ALL_SOURCE_TYPES.YOUTUBE
|
||||
|
||||
|
||||
class PageType(Enum):
|
||||
|
||||
@@ -10,7 +10,6 @@ from ..abstract import Page
|
||||
from ...objects import (
|
||||
Artist,
|
||||
Source,
|
||||
SourcePages,
|
||||
Song,
|
||||
Album,
|
||||
Label,
|
||||
@@ -21,6 +20,7 @@ from ...objects import (
|
||||
from ...connection import Connection
|
||||
from ...utils.support_classes.download_result import DownloadResult
|
||||
from ...utils.config import youtube_settings, logging_settings, main_settings
|
||||
from ...utils.enums import SourceType, ALL_SOURCE_TYPES
|
||||
|
||||
|
||||
def get_invidious_url(path: str = "", params: str = "", query: str = "", fragment: str = "") -> str:
|
||||
@@ -50,7 +50,7 @@ class YouTubeUrl:
|
||||
"""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self.SOURCE_TYPE = SourcePages.YOUTUBE
|
||||
self.SOURCE_TYPE = ALL_SOURCE_TYPES.YOUTUBE
|
||||
|
||||
"""
|
||||
Raises Index exception for wrong url, and value error for not found enum type
|
||||
@@ -58,9 +58,6 @@ class YouTubeUrl:
|
||||
self.id = ""
|
||||
parsed = urlparse(url=url)
|
||||
|
||||
if parsed.netloc == "music.youtube.com":
|
||||
self.SOURCE_TYPE = SourcePages.YOUTUBE_MUSIC
|
||||
|
||||
self.url_type: YouTubeUrlType
|
||||
|
||||
type_frag_list = parsed.path.split("/")
|
||||
@@ -124,8 +121,7 @@ class YouTubeUrl:
|
||||
|
||||
class SuperYouTube(Page):
|
||||
# CHANGE
|
||||
SOURCE_TYPE = SourcePages.YOUTUBE
|
||||
LOGGER = logging_settings["youtube_logger"]
|
||||
SOURCE_TYPE = ALL_SOURCE_TYPES.YOUTUBE
|
||||
|
||||
NO_ADDITIONAL_DATA_FROM_SONG = False
|
||||
|
||||
@@ -145,6 +141,8 @@ class SuperYouTube(Page):
|
||||
_sponsorblock_connection: Connection = Connection()
|
||||
self.sponsorblock = python_sponsorblock.SponsorBlock(silent=True, session=_sponsorblock_connection.session)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_source_type(self, source: Source) -> Optional[Type[DatabaseObject]]:
|
||||
_url_type = {
|
||||
YouTubeUrlType.CHANNEL: Artist,
|
||||
|
||||
@@ -27,7 +27,7 @@ from ..abstract import Page
|
||||
from ...objects import (
|
||||
Artist,
|
||||
Source,
|
||||
SourcePages,
|
||||
SourceType,
|
||||
Song,
|
||||
Album,
|
||||
Label,
|
||||
@@ -176,7 +176,7 @@ ALBUM_TYPE_MAP = {
|
||||
|
||||
class YoutubeMusic(SuperYouTube):
|
||||
# CHANGE
|
||||
SOURCE_TYPE = SourcePages.YOUTUBE_MUSIC
|
||||
SOURCE_TYPE = SourceType.YOUTUBE_MUSIC
|
||||
LOGGER = logging_settings["youtube_music_logger"]
|
||||
|
||||
def __init__(self, *args, ydl_opts: dict = None, **kwargs):
|
||||
@@ -193,8 +193,7 @@ class YoutubeMusic(SuperYouTube):
|
||||
|
||||
self.start_millis = get_current_millis()
|
||||
|
||||
if self.credentials.api_key == "" or DEBUG_YOUTUBE_INITIALIZING:
|
||||
self._fetch_from_main_page()
|
||||
self._fetch_from_main_page()
|
||||
|
||||
SuperYouTube.__init__(self, *args, **kwargs)
|
||||
|
||||
@@ -215,6 +214,8 @@ class YoutubeMusic(SuperYouTube):
|
||||
self.download_values_by_url: dict = {}
|
||||
self.not_download: Dict[str, DownloadError] = {}
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _fetch_from_main_page(self):
|
||||
"""
|
||||
===API=KEY===
|
||||
@@ -618,7 +619,7 @@ class YoutubeMusic(SuperYouTube):
|
||||
Artist(
|
||||
name=name,
|
||||
source_list=[Source(
|
||||
SourcePages.YOUTUBE_MUSIC,
|
||||
SourceType.YOUTUBE_MUSIC,
|
||||
f"https://music.youtube.com/channel/{ydl_res.get('channel_id', ydl_res.get('uploader_id', ''))}"
|
||||
)]
|
||||
) for name in artist_names]
|
||||
@@ -639,7 +640,7 @@ class YoutubeMusic(SuperYouTube):
|
||||
artwork=Artwork(*ydl_res.get("thumbnails", [])),
|
||||
main_artist_list=artist_list,
|
||||
source_list=[Source(
|
||||
SourcePages.YOUTUBE_MUSIC,
|
||||
SourceType.YOUTUBE_MUSIC,
|
||||
f"https://music.youtube.com/watch?v={ydl_res.get('id')}"
|
||||
), source],
|
||||
)
|
||||
@@ -736,8 +737,9 @@ class YoutubeMusic(SuperYouTube):
|
||||
raw_headers=True,
|
||||
disable_cache=True,
|
||||
headers=media.get("headers", {}),
|
||||
# chunk_size=media.get("chunk_size", main_settings["chunk_size"]),
|
||||
chunk_size=main_settings["chunk_size"],
|
||||
method="GET",
|
||||
timeout=5,
|
||||
)
|
||||
else:
|
||||
result = DownloadResult(error_message=str(media.get("error") or self.not_download[source.hash_url]))
|
||||
|
||||
@@ -19,8 +19,13 @@ def _apply_color(msg: str, color: BColors) -> str:
|
||||
if not isinstance(msg, str):
|
||||
msg = str(msg)
|
||||
|
||||
endc = BColors.ENDC.value
|
||||
|
||||
if color is BColors.ENDC:
|
||||
return msg
|
||||
|
||||
msg = msg.replace(BColors.ENDC.value, BColors.ENDC.value + color.value)
|
||||
|
||||
return color.value + msg + BColors.ENDC.value
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,53 @@
|
||||
from .source import SourcePages
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, TYPE_CHECKING, Type
|
||||
if TYPE_CHECKING:
|
||||
from ...pages.abstract import Page
|
||||
|
||||
@dataclass
|
||||
class SourceType:
|
||||
name: str
|
||||
homepage: Optional[str] = None
|
||||
download_priority: int = 0
|
||||
|
||||
page_type: Type[Page] = None
|
||||
page: Page = None
|
||||
|
||||
|
||||
def register_page(self, page_type: Type[Page]):
|
||||
self.page_type = page
|
||||
self.page = page_type()
|
||||
|
||||
@property
|
||||
def has_page(self) -> bool:
|
||||
return self.page is not None
|
||||
|
||||
# for backwards compatibility
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ALL_SOURCE_TYPES:
|
||||
YOUTUBE = SourceType(name="youtube", homepage="https://music.youtube.com/")
|
||||
BANDCAMP = SourceType(name="bandcamp", homepage="https://bandcamp.com/", download_priority=10)
|
||||
MUSIFY = SourceType(name="musify", homepage="https://musify.club/", download_priority=7)
|
||||
|
||||
GENIUS = SourceType(name="genius", homepage="https://genius.com/")
|
||||
MUSICBRAINZ = SourceType(name="musicbrainz", homepage="https://musicbrainz.org/")
|
||||
ENCYCLOPAEDIA_METALLUM = SourceType(name="encyclopaedia metallum")
|
||||
DEEZER = SourceType(name="deezer", homepage="https://www.deezer.com/")
|
||||
SPOTIFY = SourceType(name="spotify", homepage="https://open.spotify.com/")
|
||||
|
||||
# This has nothing to do with audio, but bands can be here
|
||||
WIKIPEDIA = SourceType(name="wikipedia", homepage="https://en.wikipedia.org/wiki/Main_Page")
|
||||
INSTAGRAM = SourceType(name="instagram", homepage="https://www.instagram.com/")
|
||||
FACEBOOK = SourceType(name="facebook", homepage="https://www.facebook.com/")
|
||||
TWITTER = SourceType(name="twitter", homepage="https://twitter.com/")
|
||||
# Yes somehow this ancient site is linked EVERYWHERE
|
||||
MYSPACE = SourceType(name="myspace", homepage="https://myspace.com/")
|
||||
|
||||
MANUAL = SourceType(name="manual")
|
||||
|
||||
PRESET = SourceType(name="preset")
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SourceTypes(Enum):
|
||||
SONG = "song"
|
||||
ALBUM = "album"
|
||||
ARTIST = "artist"
|
||||
LYRICS = "lyrics"
|
||||
|
||||
|
||||
class SourcePages(Enum):
|
||||
YOUTUBE = "youtube", "https://www.youtube.com/"
|
||||
MUSIFY = "musify", "https://musify.club/"
|
||||
YOUTUBE_MUSIC = "youtube music", "https://music.youtube.com/"
|
||||
GENIUS = "genius", "https://genius.com/"
|
||||
MUSICBRAINZ = "musicbrainz", "https://musicbrainz.org/"
|
||||
ENCYCLOPAEDIA_METALLUM = "encyclopaedia metallum"
|
||||
BANDCAMP = "bandcamp", "https://bandcamp.com/"
|
||||
DEEZER = "deezer", "https://www.deezer.com/"
|
||||
SPOTIFY = "spotify", "https://open.spotify.com/"
|
||||
|
||||
# This has nothing to do with audio, but bands can be here
|
||||
WIKIPEDIA = "wikipedia", "https://en.wikipedia.org/wiki/Main_Page"
|
||||
INSTAGRAM = "instagram", "https://www.instagram.com/"
|
||||
FACEBOOK = "facebook", "https://www.facebook.com/"
|
||||
TWITTER = "twitter", "https://twitter.com/"
|
||||
MYSPACE = "myspace", "https://myspace.com/" # Yes somehow this ancient site is linked EVERYWHERE
|
||||
|
||||
MANUAL = "manual", ""
|
||||
|
||||
PRESET = "preset", ""
|
||||
|
||||
def __new__(cls, value, homepage = None):
|
||||
member = object.__new__(cls)
|
||||
|
||||
member._value_ = value
|
||||
member.homepage = homepage
|
||||
|
||||
return member
|
||||
|
||||
@@ -1 +1,23 @@
|
||||
__all__ = ["config"]
|
||||
class MKBaseException(Exception):
|
||||
def __init__(self, message: str = None, **kwargs) -> None:
|
||||
self.message = message
|
||||
super().__init__(message, **kwargs)
|
||||
|
||||
|
||||
# Downloading
|
||||
class MKDownloadException(MKBaseException):
|
||||
pass
|
||||
|
||||
|
||||
class MKMissingNameException(MKDownloadException):
|
||||
pass
|
||||
|
||||
|
||||
# Frontend
|
||||
class MKFrontendException(MKBaseException):
|
||||
pass
|
||||
|
||||
class MKInvalidInputException(MKFrontendException):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ if not load_dotenv(Path(__file__).parent.parent.parent / ".env"):
|
||||
|
||||
__stage__ = os.getenv("STAGE", "prod")
|
||||
|
||||
DEBUG = (__stage__ == "dev") and True
|
||||
DEBUG = (__stage__ == "dev") and False
|
||||
DEBUG_LOGGING = DEBUG and False
|
||||
DEBUG_TRACE = DEBUG and True
|
||||
DEBUG_OBJECT_TRACE = DEBUG and False
|
||||
|
||||
@@ -116,10 +116,13 @@ def clean_song_title(raw_song_title: str, artist_name: Optional[str] = None) ->
|
||||
|
||||
# Remove artist from the start of the title
|
||||
if raw_song_title.lower().startswith(artist_name.lower()):
|
||||
raw_song_title = raw_song_title[len(artist_name):].strip()
|
||||
|
||||
if raw_song_title.startswith("-"):
|
||||
raw_song_title = raw_song_title[1:].strip()
|
||||
possible_new_name = raw_song_title[len(artist_name):].strip()
|
||||
|
||||
for char in ("-", "–", ":", "|"):
|
||||
if possible_new_name.startswith(char):
|
||||
raw_song_title = possible_new_name[1:].strip()
|
||||
break
|
||||
|
||||
return raw_song_title.strip()
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ dependencies = [
|
||||
"toml~=0.10.2",
|
||||
"typing_extensions~=4.7.1",
|
||||
|
||||
"python-sponsorblock~=0.0.0",
|
||||
"python-sponsorblock~=0.1",
|
||||
"youtube_dl",
|
||||
]
|
||||
dynamic = [
|
||||
|
||||
Reference in New Issue
Block a user