diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 894a44c..7be2372 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ venv.bak/ # mypy .mypy_cache/ + +# keys folder +keys/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100755 index 0000000..7005c2c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,32 @@ +# https://hub.docker.com/r/library/python/tags/ +image: python:latest + +# Change pip's cache directory to be inside the project directory since we can +# only cache local items. +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + +# Pip's cache doesn't store the python packages +# https://pip.pypa.io/en/stable/reference/pip_install/#caching +# +# If you want to also cache the installed packages, you have to install +# them in a virtualenv and cache it as well. +cache: + paths: + - .cache/pip + - venv/ + +before_script: + - python -V # Print out python version for debugging + - apt update && apt upgrade -y + - apt install -y libchromaprint-tools ffmpeg --install-recommends + - pip install -r YouTubeMDBot/requirements.txt + +test:pylint: + script: + - pip install pylint + - pylint -j 0 --exit-zero --ignored-classes=_socketobject *.py YouTubeMDBot + +test: + script: + - python -m unittest $(pwd)/YouTubeMDBot/tests/*.py diff --git a/Design/Database/YouTubeMDBot.drawio b/Design/Database/YouTubeMDBot.drawio new file mode 100644 index 0000000..6e7c827 --- /dev/null +++ b/Design/Database/YouTubeMDBot.drawio @@ -0,0 +1 @@ +7V1Nl5s2FP01XjaHb5vlfGSaTdq0c3qarObIINt0AE1AZMb59RVGwraEOwyWkZgqmxjBCNC9enq670nM3Jvs5dcCPG0+oximM8eKX2bu7cxxbNddkP/qkm1TsrCcpmBdJDG9aF9wn/yEtNCipVUSw/LoQoxQipOn48II5TmM8FEZKAr0fHzZCqXHd30CaygU3EcgFUv/TmK8oW/hBPvyTzBZb9id7SBszixB9LguUJXT+80cd7X715zOAKuLvmi5ATF6PihyP87cmwIh3PzKXm5gWrcta7bm7+5OnG2fu4A57vMHle/e383/zLIAeD+j4J8s+eP3X2yXPt0PkFa0Rf4qYUEfGW9ZM+1eFNZV2TP3+nmTYHj/BKL67DMhBinb4Cylp9tX3R3gAj3CG5SiYleVa1muGwTkzCpJ04PyW++jfefX5SjHXdeDNFnnpCwib0we0b1ep6Cs4bfI7whlSUR/1xXc02evHyEFS5het3ixunOUw/YsKmJYcGd29TSMtT1yTJsKFhi+nETBbrElfQaiDOJiSy6hf+Ay3tPuYof0+HlPPptRZnNAvICWAcr3dVv1HnPyg8L+JgrYAgVmTpCS+15X5Me6/kFqoEXkHm2pwBJyO9Jv4esMOabTEV9OgfrfIA5j2SG4vVkngQU8CXpyYHE5DjgdHODBzeOr2uDuG/0A0L6t2dQKY8Eoc01H7oyqIoJ9bBcGxRri1ykuwnHQ4H5He7OyAqYAJz+OH7kLBHqHLyghL3MKbaErN29K/+jQgnP1BFw9NldP0w5CPTtGtC99DklcgSQ5yKCxAkOtQKCdGfAEhDFYG4BlDfbqAfYFgFOQG4SHIuwFvm4IBwLCq6Qo8QOIIkjwMEgPQzpwtOvL8/fts7ka+WyuI8lnm4/tsy1GIgl8SfDXut9/8OnRN2oF6t+3L4cHW3qgiFieIZYEYoXv2/r4hiTnk8QT1cV3RZLAkEQCSU7rj0umNH5KSozqxmhFyOVJEXK4VP1/8GP10589UXuMATay0mCE/ROGQd1UxROVQyHEQJoSPpg4wyU7uw5MEBVG/T0CNkK97hE0tkwTj8DnR/KBHoHPexYX9whEZWMDROmq2KBsWZU6WIF4Hi4tS7QCqxUMoqiDthfp7QtubF+I3X0xbm8fTX3IyfN+pdjtDnbqQ6NF1Id7AWJ3dAkFgrG2x7zB6sZRiZXwePgHWgnPHdtKTFGBeMNQMjckOZ8krDkOSEKgv2J47ZlCzC7mpoZHRp6lBB3Yd1okmPLaiCcRSK/oiSyJ4/SUs7ofmyw5Y9PbWH3WgNNKBUw6sMQBx+vgKO9PyBtwfFFLIC129ZuBW4Z/4WoHtygdTGAIcHsPATqlLU13CDgtPrQi4pcCriDBMoKl0RjPNhS+dQy564uGYmSR0Rd1BzOl7IEkn/WgfErpixlM+pv8/iElX2lw2vO4jusNtPleyLkK/thGX0yD0p8mzEj1oInS5Bg+F8od7BpwEwh3dN9AVBrN9FDWfMHTb74g6pEGbllw875C25nVwS0qhKCKE/SwQkUGsDggmLBiv1xYvmOrjyuymYMA9feKtADeGqwHYj3n53LqE58DUeQriS/3UKJ8/ZAm+aMBWxLYngZgi+oNKB9rC/5A7gBigIGBW5Id9y1POdxTzA9hfobu8zZO0Q0kLUQNxl6IGkxRA2KNZkgyEkmmqAAx18aQZCSSTHENXdA7gGhIIoUkXVlsXADxc+uLmujhuVklnvPheFV1wJatq4sfBqKUZDbJuTAPTmQhq5ucsGjF4fYYCU7NUhVJEM81gFjUlkCBk9KIxbJ2ufE6UgjGBtkRQU6XVWYwHoix6+iHsagbRuhHx86GBuO+W93oNx6LYiGZskBQNgsJDdDDgPb1G5VFwa+AESriJF8bqOXZ7flcPdSibBdXBcAJyg3MklwwHWAWhbeoKjHKTFRP8qQq0MB8iwLaFlW4WppxWuLegzoAPcUlmfPe63YbIVgTOT2UtN/PYuz9fhj9pkWS3tFbQxIpJOnaFEp7kgSGJKOSRNTxJkAS35BkVJJ0Lf/UniS9VwIZkkghyRTTDtm0ypBkJJJMMu2wP0lClSQRZIuhS0/5TUn8cGyaTDHxkMW9dbclLrdCIRhqTFwuyiFslndxmkwy9bC3NWli7Mpowuce8guLe9OEj3ry4tnFaTLWFnqKaKI0Q9WdSxp0PD5ZbfRB53R+YpuHepfUqWomP1V6fmrYN93lcvmpjG8mP3XExZLHJFAfVA07khfNImgJUPPmXQesHQHrsm4dA7Esq25b6rMYQ1G8NNuW9QAz4MFcBCKW425cFk5RYmRu5euOfKjTUjPbGTrf4z/Jao893wvHEhn3W6bvd0n/RusYa8v0sPdKRtWqk8tZk/kwegkJl/yE8+L0mqI4ySY3r9NkoVTDtrldD21r6JJXx+NrGnvRazhFeZI5xdoThZ9PDNWdbG4SuhhbdwqnKE+yebL2NHk/Xo0oT5qNF2VtvOhagiDZMdsZdevFdqtPA/hFAOe8SNUbqzpWV7omv8d+CrbpbiW1CUGcu3Ebv6BHeQDCsUQ90gQgxmVBwL6kqkyvdKwJJlu2xutVd5CS3LiDZ7qDjtWlhXLDxT0G9b4bSWS+yCLBVHA7faqPWDuWKHTiJINicMMMB8Mw1iB+5VgTlBtbZvYYD5R+reUdjQcnPvhrLSHMdw31vYIlJn1bMPsm8nlsBLi01oXqDzY51gQVwtZB6WECdPpM65RNgFEILycYBVxgSr0+yIg7LavQd6cAOqU0VuFcq2BPcBl4f5oozmrgP9MZ8jHmvjTxeaeDnzWeoAmBDWwPLnuqLyhPPzD/VcEFtU971jU1SubgJDWt3hzUK8TZhjbONVV9vzj5Zg7ySsp5HCSHBUL48HLibmw+oxjWV/wL \ No newline at end of file diff --git a/Design/Database/YouTubeMDBot.pdf b/Design/Database/YouTubeMDBot.pdf new file mode 100644 index 0000000..3754129 Binary files /dev/null and b/Design/Database/YouTubeMDBot.pdf differ diff --git a/Design/Database/YouTubeMDBot.png b/Design/Database/YouTubeMDBot.png new file mode 100644 index 0000000..c003094 Binary files /dev/null and b/Design/Database/YouTubeMDBot.png differ diff --git a/Design/Database/YouTubeMDBot.svg b/Design/Database/YouTubeMDBot.svg new file mode 100644 index 0000000..95d49cb --- /dev/null +++ b/Design/Database/YouTubeMDBot.svg @@ -0,0 +1,3 @@ + + +
User
User
id
[Not supported by viewer]
name
name
tag
tag
lang
lang
first_access
first_access
History
<b>History</b>
date
date
file_id
[Not supported by viewer]
has
has
1:1
1:1
0:N
0:N
Preferences
<b>Preferences</b>
has
has
1:1
1:1
1:1
1:1
audio_format
audio_format
audio_quality
audio_quality
send_song_link
send_song_link
ask_for_metadata
ask_for_metadata
Metadata
<b>Metadata</b>
id
[Not supported by viewer]
title
title
artist
artist
album
album
cover
cover
release_id
release_id
recording_id
recording_id
duration
duration
custom_metadata
custom_metadata
youtube_id
youtube_id
File
[Not supported by viewer]
id
[Not supported by viewer]
audio_quality
audio_quality
size
size
has
has
1:1
1:1
1:1
1:1
Playlist
<b>Playlist</b>
id
[Not supported by viewer]
Statistics
<b>Statistics</b>
times
times
has been requested
has been requested
1:1
1:1
\ No newline at end of file diff --git a/Design/Database/database_model.mwb b/Design/Database/database_model.mwb deleted file mode 100644 index 03ffdc9..0000000 Binary files a/Design/Database/database_model.mwb and /dev/null differ diff --git a/Design/Database/database_model.mwb.bak b/Design/Database/database_model.mwb.bak deleted file mode 100644 index ea968e3..0000000 Binary files a/Design/Database/database_model.mwb.bak and /dev/null differ diff --git a/Design/Database/generated_sql_file.sql b/Design/Database/generated_sql_file.sql deleted file mode 100644 index e65336e..0000000 --- a/Design/Database/generated_sql_file.sql +++ /dev/null @@ -1,251 +0,0 @@ --- MySQL Script generated by MySQL Workbench --- lun 22 jul 2019 14:28:48 CEST --- Model: New Model Version: 1.0 --- MySQL Workbench Forward Engineering - -SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; -SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; -SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; - --- ----------------------------------------------------- --- Schema youtubemd --- ----------------------------------------------------- - --- ----------------------------------------------------- --- Schema youtubemd --- ----------------------------------------------------- -CREATE SCHEMA IF NOT EXISTS `youtubemd` DEFAULT CHARACTER SET utf8mb4 ; -SHOW WARNINGS; -USE `youtubemd` ; - --- ----------------------------------------------------- --- Table `youtubemd`.`User` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`User` ( - `id` INT(64) NOT NULL DEFAULT 0, - `name` VARCHAR(45) NULL DEFAULT 'User', - `surname` VARCHAR(45) NULL, - `username` VARCHAR(45) NULL, - `lastSeen` DATETIME NOT NULL, - `firstUsage` DATETIME NOT NULL, - PRIMARY KEY (`id`)) -ENGINE = InnoDB -CHECKSUM = 1 -PACK_KEYS = 1; - -SHOW WARNINGS; -CREATE UNIQUE INDEX `id_UNIQUE` ON `youtubemd`.`User` (`id` ASC) VISIBLE; - -SHOW WARNINGS; -CREATE UNIQUE INDEX `username_UNIQUE` ON `youtubemd`.`User` (`username` ASC) VISIBLE; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`Preferences` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`Preferences` ( - `language` VARCHAR(3) NOT NULL DEFAULT 'en', - `audioQuality` ENUM('320k', '256k', '128k') NOT NULL DEFAULT '128k', - `audioSampling` ENUM('44000', '48000') NOT NULL DEFAULT '44000', - `sendSongLinks` TINYINT NOT NULL DEFAULT 0, - `User_id` INT(64) NOT NULL, - PRIMARY KEY (`User_id`), - CONSTRAINT `fk_Preferences_User` - FOREIGN KEY (`User_id`) - REFERENCES `youtubemd`.`User` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`Metadata` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`Metadata` ( - `idMetadata` INT NOT NULL AUTO_INCREMENT, - `title` VARCHAR(100) NOT NULL, - `artist` VARCHAR(60) NOT NULL, - `cover` BLOB NOT NULL, - `duration` INT NULL, - `customMetadata` TINYINT NOT NULL DEFAULT 0, - PRIMARY KEY (`idMetadata`)) -ENGINE = InnoDB -AUTO_INCREMENT = 0 -CHECKSUM = 1; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`VideoInformation` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`VideoInformation` ( - `id` VARCHAR(11) NOT NULL, - `title` VARCHAR(100) NOT NULL, - `channel` VARCHAR(60) NOT NULL, - PRIMARY KEY (`id`)) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; -CREATE UNIQUE INDEX `id_UNIQUE` ON `youtubemd`.`VideoInformation` (`id` ASC) VISIBLE; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`DownloadInformation` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`DownloadInformation` ( - `file_id` VARCHAR(50) NOT NULL, - `audioQuality` ENUM('320k', '256k', '128k') NOT NULL, - `audioSampling` ENUM('44000', '48000') NULL, - `Metadata_idMetadata` INT NOT NULL, - `VideoInformation_id` VARCHAR(11) NOT NULL, - PRIMARY KEY (`file_id`, `Metadata_idMetadata`, `VideoInformation_id`), - CONSTRAINT `fk_DownloadInformation_Metadata1` - FOREIGN KEY (`Metadata_idMetadata`) - REFERENCES `youtubemd`.`Metadata` (`idMetadata`) - ON DELETE NO ACTION - ON UPDATE NO ACTION, - CONSTRAINT `fk_DownloadInformation_VideoInformation1` - FOREIGN KEY (`VideoInformation_id`) - REFERENCES `youtubemd`.`VideoInformation` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; -CREATE UNIQUE INDEX `file_id_UNIQUE` ON `youtubemd`.`DownloadInformation` (`file_id` ASC) VISIBLE; - -SHOW WARNINGS; -CREATE INDEX `fk_DownloadInformation_Metadata1_idx` ON `youtubemd`.`DownloadInformation` (`Metadata_idMetadata` ASC) VISIBLE; - -SHOW WARNINGS; -CREATE INDEX `fk_DownloadInformation_VideoInformation1_idx` ON `youtubemd`.`DownloadInformation` (`VideoInformation_id` ASC) VISIBLE; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`History` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`History` ( - `User_id` INT(64) NOT NULL, - `DownloadInformation_file_id` VARCHAR(50) NOT NULL, - PRIMARY KEY (`User_id`, `DownloadInformation_file_id`), - CONSTRAINT `fk_History_User1` - FOREIGN KEY (`User_id`) - REFERENCES `youtubemd`.`User` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION, - CONSTRAINT `fk_History_DownloadInformation1` - FOREIGN KEY (`DownloadInformation_file_id`) - REFERENCES `youtubemd`.`DownloadInformation` (`file_id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; -CREATE INDEX `fk_History_DownloadInformation1_idx` ON `youtubemd`.`History` (`DownloadInformation_file_id` ASC) VISIBLE; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`VideoStatistics` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`VideoStatistics` ( - `timesRequested` INT NOT NULL DEFAULT 0, - `VideoInformation_id` VARCHAR(11) NOT NULL, - PRIMARY KEY (`VideoInformation_id`), - CONSTRAINT `fk_VideoStatistics_VideoInformation1` - FOREIGN KEY (`VideoInformation_id`) - REFERENCES `youtubemd`.`VideoInformation` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`DownloadStatistics` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`DownloadStatistics` ( - `timesRequested` INT NOT NULL DEFAULT 0, - `DownloadInformation_file_id` VARCHAR(50) NOT NULL, - PRIMARY KEY (`DownloadInformation_file_id`), - CONSTRAINT `fk_DownloadStatistics_DownloadInformation1` - FOREIGN KEY (`DownloadInformation_file_id`) - REFERENCES `youtubemd`.`DownloadInformation` (`file_id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`Playlist` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`Playlist` ( - `id` VARCHAR(60) NOT NULL, - PRIMARY KEY (`id`)) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; -CREATE UNIQUE INDEX `id_UNIQUE` ON `youtubemd`.`Playlist` (`id` ASC) VISIBLE; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`Playlist_has_VideoInformation` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`Playlist_has_VideoInformation` ( - `Playlist_id` VARCHAR(60) NOT NULL, - `VideoInformation_id` VARCHAR(11) NOT NULL, - PRIMARY KEY (`Playlist_id`, `VideoInformation_id`), - CONSTRAINT `fk_Playlist_has_VideoInformation_Playlist1` - FOREIGN KEY (`Playlist_id`) - REFERENCES `youtubemd`.`Playlist` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION, - CONSTRAINT `fk_Playlist_has_VideoInformation_VideoInformation1` - FOREIGN KEY (`VideoInformation_id`) - REFERENCES `youtubemd`.`VideoInformation` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; -CREATE INDEX `fk_Playlist_has_VideoInformation_VideoInformation1_idx` ON `youtubemd`.`Playlist_has_VideoInformation` (`VideoInformation_id` ASC) VISIBLE; - -SHOW WARNINGS; -CREATE INDEX `fk_Playlist_has_VideoInformation_Playlist1_idx` ON `youtubemd`.`Playlist_has_VideoInformation` (`Playlist_id` ASC) VISIBLE; - -SHOW WARNINGS; - --- ----------------------------------------------------- --- Table `youtubemd`.`PlaylistStatistics` --- ----------------------------------------------------- -CREATE TABLE IF NOT EXISTS `youtubemd`.`PlaylistStatistics` ( - `timesRequested` INT NOT NULL, - `Playlist_id` VARCHAR(60) NOT NULL, - PRIMARY KEY (`Playlist_id`), - CONSTRAINT `fk_PlaylistStatistics_Playlist1` - FOREIGN KEY (`Playlist_id`) - REFERENCES `youtubemd`.`Playlist` (`id`) - ON DELETE NO ACTION - ON UPDATE NO ACTION) -ENGINE = InnoDB -CHECKSUM = 1; - -SHOW WARNINGS; - -SET SQL_MODE=@OLD_SQL_MODE; -SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; -SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; diff --git a/Design/Database/img_database_model.png b/Design/Database/img_database_model.png deleted file mode 100644 index 6046766..0000000 Binary files a/Design/Database/img_database_model.png and /dev/null differ diff --git a/Design/Database/new_database_model.mwb b/Design/Database/new_database_model.mwb new file mode 100644 index 0000000..8a3992e Binary files /dev/null and b/Design/Database/new_database_model.mwb differ diff --git a/Design/Database/pdf_database_model.pdf b/Design/Database/pdf_database_model.pdf deleted file mode 100644 index 4cdb445..0000000 Binary files a/Design/Database/pdf_database_model.pdf and /dev/null differ diff --git a/Design/Database/psql_model.sql b/Design/Database/psql_model.sql new file mode 100644 index 0000000..7fffb89 --- /dev/null +++ b/Design/Database/psql_model.sql @@ -0,0 +1,266 @@ +-- PostgreSQL model for YouTubeMDBot application +-- Created by Javinator9889 - thu, 24 October, 2019 +-- Last modification: mon, 4 November, 2019 +-- Version 1.1 + +-- DROP schema - only for testing +-- DROP SCHEMA IF EXISTS youtubemd CASCADE; +-- DROP TYPE IF EXISTS AFORMAT; +-- DROP TYPE IF EXISTS aquality; +-- DROP TYPE IF EXISTS behaviour; + +-- Custom "enum" types +CREATE TYPE AFORMAT AS ENUM ('mp3', 'm4a', 'ogg'); +CREATE TYPE AQUALITY AS ENUM ('128k', '96k'); +CREATE TYPE BEHAVIOUR AS ENUM ('always', 'not_found', 'ask', 'never'); + +-- Create DB schema +CREATE SCHEMA IF NOT EXISTS youtubemd; + +-- --------------------------------------- +-- Table User -- +-- --------------------------------------- +CREATE TABLE IF NOT EXISTS youtubemd.User +( + "id" INT PRIMARY KEY NOT NULL, + "name" VARCHAR(45), + "tag" VARCHAR(45), + "lang" VARCHAR(2), + "first_access" date +); + +-- --------------------------------------------- +-- Table Preferences -- +-- --------------------------------------------- +CREATE TABLE IF NOT EXISTS youtubemd.Preferences +( + "audio_format" AFORMAT NOT NULL DEFAULT 'm4a', + "audio_quality" AQUALITY NOT NULL DEFAULT '128k', + "song_behaviour" BEHAVIOUR NOT NULL DEFAULT 'not_found', + "send_song_link" BOOLEAN DEFAULT False, + "user_id" INT NOT NULL, + PRIMARY KEY ("user_id"), + CONSTRAINT "fk_user_id" + FOREIGN KEY ("user_id") + REFERENCES youtubemd.User ("id") + ON DELETE CASCADE + ON UPDATE CASCADE +); + +-- ------------------------------------------ +-- Table YouTube -- +-- ------------------------------------------ +CREATE TABLE IF NOT EXISTS youtubemd.YouTube +( + "id" VARCHAR(11) UNIQUE NOT NULL, + "times_requested" INT NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ------------------------------------------ +-- Table Metadata -- +-- ------------------------------------------ +CREATE TABLE IF NOT EXISTS youtubemd.Metadata +( + "id" BIGSERIAL NOT NULL, + "artist" VARCHAR(45) NOT NULL, + "album" VARCHAR(45) NOT NULL, + "cover" BYTEA NOT NULL, + "release_id" VARCHAR(36), + "recording_id" VARCHAR(36), + "duration" INT, + "title" VARCHAR(45), + "custom_metadata" BOOLEAN, + PRIMARY KEY ("id") +); + +-- ---------------------------------------------------- +-- Relation between YouTube and Metadata -- +-- ---------------------------------------------------- +CREATE TABLE IF NOT EXISTS youtubemd.Video_Has_Metadata +( + "id" VARCHAR(11) NOT NULL, + "metadata_id" INT NOT NULL, + PRIMARY KEY ("id", "metadata_id"), + CONSTRAINT "fk_video_id" + FOREIGN KEY ("id") + REFERENCES youtubemd.YouTube ("id"), + CONSTRAINT "fk_metadata_id" + FOREIGN KEY ("metadata_id") + REFERENCES youtubemd.Metadata ("id") +); + +-- -------------------------------------- +-- Table File -- +-- -------------------------------------- +CREATE TABLE IF NOT EXISTS youtubemd.File +( + "id" VARCHAR(50) UNIQUE NOT NULL, + "metadata_id" INT UNIQUE NOT NULL, + "audio_quality" AQUALITY NOT NULL, + "size" INT, + PRIMARY KEY ("id", "metadata_id"), + CONSTRAINT "fk_metadata_id" + FOREIGN KEY ("metadata_id") + REFERENCES youtubemd.Metadata ("id") +); + +-- ----------------------------------------- +-- Table History -- +-- ----------------------------------------- +CREATE TABLE IF NOT EXISTS youtubemd.History +( + "id" BIGSERIAL NOT NULL, + "file_id" VARCHAR(50) NOT NULL, + "user_id" INT NOT NULL, + "metadata_id" INT NOT NULL, + "date" date, + PRIMARY KEY ("id", "file_id", "user_id", "metadata_id"), + CONSTRAINT "fk_user_id" + FOREIGN KEY ("user_id") + REFERENCES youtubemd.User ("id") + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT "fk_file_id" + FOREIGN KEY ("file_id") + REFERENCES youtubemd.File ("id"), + CONSTRAINT "fk_metadata_id" + FOREIGN KEY ("metadata_id") + REFERENCES youtubemd.Metadata ("id") +); + +-- ------------------------------------------ +-- Table Playlist -- +-- ------------------------------------------ +CREATE TABLE IF NOT EXISTS youtubemd.Playlist +( + "id" VARCHAR(22) NOT NULL UNIQUE, + PRIMARY KEY ("id") +); + +-- ---------------------------------------------- +-- Table YouTube stats -- +-- ---------------------------------------------- +CREATE TABLE IF NOT EXISTS youtubemd.YouTubeStats +( + "id" VARCHAR(11) NOT NULL UNIQUE, + "daily_requests" INT NOT NULL DEFAULT 1, + "weekly_requests" INT NOT NULL DEFAULT 1, + "monthly_requests" INT NOT NULL DEFAULT 1, + PRIMARY KEY ("id"), + CONSTRAINT "fk_youtube_id" + FOREIGN KEY ("id") + REFERENCES youtubemd.YouTube ("id") +); + +-- Additional indexes +CREATE INDEX youtubemd.user_preferences_ix ON youtubemd.Preferences ("user_id"); +CREATE INDEX youtubemd.video_metadata_ix ON youtubemd.Video_Has_Metadata ("id", "metadata_id"); +CREATE INDEX youtubemd.history_ix ON youtubemd.History ("id", "file_id", "user_id", "metadata_id"); + +-- Trigger that updated the different stats +CREATE FUNCTION youtubemd.process_stats() RETURNS trigger AS +$$ +DECLARE + daily_value INT; + weekly_value INT; + monthly_value INT; +BEGIN + IF (SELECT EXISTS(SELECT 1 FROM youtubemd.YouTubeStats WHERE youtubemd.YouTubeStats.id = NEW.id)) THEN + SELECT INTO daily_value, weekly_value, monthly_value youtubemd.YouTubeStats.daily_requests, + youtubemd.YouTubeStats.weekly_requests, + youtubemd.YouTubeStats.monthly_requests + FROM youtubemd.YouTubeStats + WHERE youtubemd.YouTubeStats.id = NEW.id; + daily_value = daily_value + 1; + weekly_value = weekly_value + 1; + monthly_value = monthly_value + 1; + + UPDATE youtubemd.YouTubeStats + SET daily_requests = daily_value, + weekly_requests = weekly_value, + monthly_requests = monthly_value + WHERE id = NEW.id; + ELSE + INSERT INTO youtubemd.YouTubeStats (id) VALUES (NEW.id); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Complementary functions with useful operations +CREATE FUNCTION youtubemd.top_10_daily() + RETURNS TABLE + ( + id VARCHAR(11), + daily_requests INT + ) +AS +$$ +BEGIN + RETURN QUERY SELECT DISTINCT youtubemd.YouTubeStats.id, youtubemd.YouTubeStats.daily_requests + FROM youtubemd.youtubestats + ORDER BY daily_requests DESC + FETCH FIRST 10 ROWS ONLY; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION youtubemd.top_10_weekly() + RETURNS TABLE + ( + id VARCHAR(11), + weekly_requests INT + ) +AS +$$ +BEGIN + RETURN QUERY SELECT DISTINCT youtubemd.YouTubeStats.id, youtubemd.YouTubeStats.weekly_requests + FROM youtubemd.youtubestats + ORDER BY weekly_requests DESC + FETCH FIRST 10 ROWS ONLY; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION youtubemd.top_10_monthly() + RETURNS TABLE + ( + id VARCHAR(11), + monthly_requests INT + ) +AS +$$ +BEGIN + RETURN QUERY SELECT DISTINCT youtubemd.YouTubeStats.id, youtubemd.YouTubeStats.monthly_requests + FROM youtubemd.youtubestats + ORDER BY monthly_requests DESC + FETCH FIRST 10 ROWS ONLY; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION youtubemd.clear_daily_stats() AS +$$ +BEGIN + UPDATE youtubemd.YouTubeStats SET daily_requests = 0; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION youtubemd.clear_weekly_stats() AS +$$ +BEGIN + UPDATE youtubemd.YouTubeStats SET weekly_requests = 0; +END; +$$ LANGUAGE plpgsql; + +CREATE FUNCTION youtubemd.clear_monthly_stats() AS +$$ +BEGIN + UPDATE youtubemd.YouTubeStats SET monthly_requests = 0; +END; +$$ LANGUAGE plpgsql; + +-- Init the trigger +CREATE TRIGGER stats_update + AFTER INSERT OR UPDATE + ON youtubemd.YouTube + FOR EACH ROW +EXECUTE PROCEDURE youtubemd.process_stats(); diff --git a/Design/Database/vect_database_model.svg b/Design/Database/vect_database_model.svg deleted file mode 100644 index fe40b7a..0000000 --- a/Design/Database/vect_database_model.svg +++ /dev/null @@ -1,3111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Design/SRS/Software Requirement Specification.docx b/Design/SRS/Software Requirement Specification.docx old mode 100644 new mode 100755 diff --git a/Design/SRS/Software Requirement Specification.pdf b/Design/SRS/Software Requirement Specification.pdf old mode 100644 new mode 100755 diff --git a/Design/logo/logo.png b/Design/logo/logo.png old mode 100644 new mode 100755 diff --git a/Design/logo/logo.psd b/Design/logo/logo.psd old mode 100644 new mode 100755 diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/YouTubeMDBot/.idea/.gitignore b/YouTubeMDBot/.idea/.gitignore new file mode 100755 index 0000000..0e40fe8 --- /dev/null +++ b/YouTubeMDBot/.idea/.gitignore @@ -0,0 +1,3 @@ + +# Default ignored files +/workspace.xml \ No newline at end of file diff --git a/YouTubeMDBot/.idea/YouTubeMDBot.iml b/YouTubeMDBot/.idea/YouTubeMDBot.iml new file mode 100755 index 0000000..6711606 --- /dev/null +++ b/YouTubeMDBot/.idea/YouTubeMDBot.iml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/YouTubeMDBot/.idea/codeStyles/codeStyleConfig.xml b/YouTubeMDBot/.idea/codeStyles/codeStyleConfig.xml new file mode 100755 index 0000000..a55e7a1 --- /dev/null +++ b/YouTubeMDBot/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/YouTubeMDBot/.idea/dictionaries/javinator9889.xml b/YouTubeMDBot/.idea/dictionaries/javinator9889.xml new file mode 100755 index 0000000..787b6ce --- /dev/null +++ b/YouTubeMDBot/.idea/dictionaries/javinator9889.xml @@ -0,0 +1,10 @@ + + + + acoustid + ffmpeg + fpcalc + javinator + + + \ No newline at end of file diff --git a/YouTubeMDBot/.idea/git_toolbox_prj.xml b/YouTubeMDBot/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..c7846c0 --- /dev/null +++ b/YouTubeMDBot/.idea/git_toolbox_prj.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/YouTubeMDBot/.idea/inspectionProfiles/profiles_settings.xml b/YouTubeMDBot/.idea/inspectionProfiles/profiles_settings.xml new file mode 100755 index 0000000..105ce2d --- /dev/null +++ b/YouTubeMDBot/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/YouTubeMDBot/.idea/misc.xml b/YouTubeMDBot/.idea/misc.xml new file mode 100755 index 0000000..8656114 --- /dev/null +++ b/YouTubeMDBot/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/YouTubeMDBot/.idea/modules.xml b/YouTubeMDBot/.idea/modules.xml new file mode 100755 index 0000000..48cc268 --- /dev/null +++ b/YouTubeMDBot/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/YouTubeMDBot/.idea/vcs.xml b/YouTubeMDBot/.idea/vcs.xml new file mode 100755 index 0000000..6c0b863 --- /dev/null +++ b/YouTubeMDBot/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/YouTubeMDBot/__init__.py b/YouTubeMDBot/__init__.py new file mode 100755 index 0000000..986bc7c --- /dev/null +++ b/YouTubeMDBot/__init__.py @@ -0,0 +1,43 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from .api import YouTubeAPI +from .api import YouTubeVideoData + +from .audio import FPCalc +from .audio import FFmpegOGG +from .audio import FFmpegMP3 +from .audio import FFmpegOpener +from .audio import ffmpeg_available + +from .commands import StartHandler + +from .constants import * + +from .decorators import restricted +from .decorators import send_action + +from .downloader import YouTubeDownloader + +from .errors import EmptyBodyError + +from .logging_utils import LoggingHandler +from .logging_utils import setup_logging + +from .metadata import AudioMetadata +from .metadata import MetadataIdentifier +from .metadata import YouTubeMetadataIdentifier + +from .utils import get_yt_video_id diff --git a/YouTubeMDBot/__main__.py b/YouTubeMDBot/__main__.py new file mode 100755 index 0000000..8a858f1 --- /dev/null +++ b/YouTubeMDBot/__main__.py @@ -0,0 +1,15 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . diff --git a/YouTubeMDBot/api/__init__.py b/YouTubeMDBot/api/__init__.py new file mode 100755 index 0000000..4ba8496 --- /dev/null +++ b/YouTubeMDBot/api/__init__.py @@ -0,0 +1,17 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..api.youtube_api import YouTubeAPI +from ..api.youtube_api import YouTubeVideoData diff --git a/YouTubeMDBot/api/youtube_api.py b/YouTubeMDBot/api/youtube_api.py new file mode 100755 index 0000000..d8ff82f --- /dev/null +++ b/YouTubeMDBot/api/youtube_api.py @@ -0,0 +1,143 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from isodate import parse_duration + +from ..constants import YOUTUBE +from ..errors import EmptyBodyError + + +class YouTubeVideoData: + """ + Obtains YouTube video data and wraps it inside this class. All fields are direct + access available, so it is possible to access them directly: + - title + - id + - thumbnail + - artist + - duration + - views + - likes + - dislikes + """ + + def __init__(self, data: dict, ignore_errors: bool = False): + """ + By passing a dict with the YouTube data (YouTube API v3), generate and obtain + the information available from the result. + :param data: a dictionary with the information obtained from YouTube API. + :param ignore_errors: whether to ignore or not errors (do not raise exceptions). + :raises EmptyBodyError when there is no information available and ignored + errors is False. + """ + if not data.get("items"): + raise EmptyBodyError("The data object has no items") + self.id: str = "" + self.title: str = "" + self.thumbnail: str = "" + self.artist: str = "" + self.duration: float = 0.0 + self.views: int = 0 + self.likes: int = 0 + self.dislikes: int = 0 + if len(data.get("items")) >= 1: + content = data.get("items")[0] + snippet = content.get("snippet") + details = content.get("contentDetails") + statistics = content.get("statistics") + if not snippet and not ignore_errors: + raise EmptyBodyError("No information available to requested video") + elif not snippet and ignore_errors: + snippet_available = False + else: + snippet_available = True + if not details and not ignore_errors: + raise EmptyBodyError("No video details available") + elif not details and ignore_errors: + details_available = False + else: + details_available = True + if not statistics and not ignore_errors: + raise EmptyBodyError("No statistics available") + elif not statistics and ignore_errors: + statistics_available = False + else: + statistics_available = True + c_id = content.get("id", "") + self.id = c_id.get("videoId", "") if isinstance(c_id, dict) else c_id + if snippet_available: + self.title = snippet["title"] + try: + self.thumbnail = snippet["thumbnails"]["maxres"]["url"] + except KeyError: + try: + self.thumbnail = snippet["thumbnails"]["high"]["url"] + except KeyError: + try: + self.thumbnail = snippet["thumbnails"]["medium"]["url"] + except KeyError: + self.thumbnail = snippet["thumbnails"]["default"]["url"] + self.artist = snippet["channelTitle"] + if details_available: + self.duration = parse_duration(details["duration"]).total_seconds() + if statistics_available: + self.views = int(statistics["viewCount"]) + self.likes = int(statistics["likeCount"]) + self.dislikes = int(statistics["dislikeCount"]) + + +class YouTubeAPI: + """ + Wrapper for the YouTube API data. Allows the developer searching for videos and, + with a given video ID, obtain its data. + """ + + def __init__(self): + from googleapiclient.discovery import build + + self.__youtube = build(serviceName=YOUTUBE["api"]["name"], + version=YOUTUBE["api"]["version"], + developerKey=YOUTUBE["key"]) + + def search(self, term: str) -> dict: + """ + Searchs for a video with the specified term. + :param term: the search term. + :return: dict with YouTube data - can be wrapped inside "YouTubeVideoData" class. + """ + return self.__youtube.search().list( + q=term, + type="video", + part="id,snippet", + maxResults=1 + ).execute() + + @staticmethod + def video_details(video_id: str) -> YouTubeVideoData: + """ + Generates a "YouTubeVideoData" object wrapper with the video ID information. + :param video_id: YouTube video ID. + :return: YouTubeVideoData object with the available metadata. + """ + try: + import ujson as json + except ImportError: + import json + from urllib.request import urlopen + + youtube_information = YOUTUBE.copy() + api_url = youtube_information["endpoint"].format(video_id, YOUTUBE["key"]) + data = urlopen(url=api_url) + return YouTubeVideoData(data=json.loads(data.read()), ignore_errors=True) diff --git a/YouTubeMDBot/audio/__init__.py b/YouTubeMDBot/audio/__init__.py new file mode 100755 index 0000000..49eab31 --- /dev/null +++ b/YouTubeMDBot/audio/__init__.py @@ -0,0 +1,20 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..audio.ffmpeg import FFmpegOpener +from ..audio.ffmpeg import FFmpegOGG +from ..audio.ffmpeg import FFmpegMP3 +from ..audio.ffmpeg import ffmpeg_available +from ..audio.fpcalc import FPCalc diff --git a/YouTubeMDBot/audio/ffmpeg.py b/YouTubeMDBot/audio/ffmpeg.py new file mode 100755 index 0000000..f94c5ae --- /dev/null +++ b/YouTubeMDBot/audio/ffmpeg.py @@ -0,0 +1,171 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from abc import ABC +from abc import abstractmethod +from typing import List +from subprocess import PIPE +from subprocess import Popen + +from ..constants import FFMPEG_OPENER +from ..constants import FFMPEG_CONVERTER + + +def ffmpeg_available() -> bool: + """ + Checks if "ffmpeg" is installed or not. + :return: True if installed, else False + """ + try: + proc = Popen(["ffmpeg", "-version"], + stdout=PIPE, + stderr=PIPE) + except OSError: + return False + else: + proc.wait() + return proc.returncode == 0 + + +class FFmpeg(ABC): + """ + Base abstract class for the FFmpeg operators. All classes that works with FFmpeg + must inherit from this class in order to maintain readability and code optimization. + + Allows execution of the ffmpeg command by using the subprocess module. Everything + is working with PIPEs, so there is no directly discs operations (everything is + loaded and working with RAM). + """ + + def __init__(self, data: bytes, command: List[str] = None): + """ + Creates the class by passing the data which will be processed and the command ( + by default, None). + :param data: audio data that will be processed. + :param command: the ffmpeg command. + """ + self._data = data + self.__command = command + self.__out = None + self.__err = None + + def process(self) -> int: + """ + Runs the ffmpeg command in a separate process and pipes both stdout and stderr. + :return: the return code of the operation ('0' if everything is OK, > 0 if not). + """ + proc = Popen(self.__command, stdout=PIPE, stderr=PIPE, stdin=PIPE) + self.__out, self.__err = proc.communicate(self._data) + return proc.returncode + + def get_command(self) -> List[str]: + """ + Get the command for editing. + :return: List[str] with the command - as this is a pointer, all editions done + to the list are directly changing the self object. + """ + return self.__command + + def set_command(self, command: List[str]): + """ + Sets the new list, overriding every old implementation. + :param command: the new command. + """ + self.__command = command + + def get_output(self) -> bytes: + """ + Gets the stdout of the process. + :return: bytes with the command output. + """ + return self.__out + + def get_extra(self) -> bytes: + """ + Gets the stderr of the process. + :return: bytes with extra information. + """ + return self.__err + + +class FFmpegOpener(FFmpeg): + """ + Opens and produces and audio in PWM mode. + """ + + def __init__(self, data: bytes): + super().__init__(data=data, command=FFMPEG_OPENER.copy()) + + +class FFmpegExporter(FFmpeg): + """ + Base class for the exporter options available in ffmpeg. + All classes that are developed for converting audio files must inherit from this + class and implement the "convert" method. + """ + + def __init__(self, data: bytes, bitrate: str = None): + """ + Generates a new instance of the class. + :param data: the audio data. + :param bitrate: the new bitrate of the audio data, or None for keeping its + default. + """ + super().__init__(data=data, command=FFMPEG_CONVERTER.copy()) + self._bitrate = bitrate + + @abstractmethod + def convert(self) -> int: + """ + Converts the audio to the desired format. + :return: the operation result code. + :raises NotImplementedError when trying to access this method directly on super + class. + """ + raise NotImplementedError + + +class FFmpegMP3(FFmpegExporter): + """ + Exports audio data to MP3 format. + """ + def convert(self) -> int: + command = super().get_command() + if self._bitrate: + command.append("-b:a") + command.append(self._bitrate) + command.append("-acodec") + command.append("libmp3lame") + command.append("-f") + command.append("mp3") + command.append("-") + return self.process() + + +class FFmpegOGG(FFmpegExporter): + """ + Exports audio data to OGG format. + """ + def convert(self) -> int: + command = super().get_command() + if self._bitrate: + command.append("-b:a") + command.append(self._bitrate) + command.append("-c:a") + command.append("libvorbis") + command.append("-f") + command.append("ogg") + command.append("-") + return self.process() diff --git a/YouTubeMDBot/audio/fpcalc.py b/YouTubeMDBot/audio/fpcalc.py new file mode 100755 index 0000000..5629b12 --- /dev/null +++ b/YouTubeMDBot/audio/fpcalc.py @@ -0,0 +1,70 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import re +from subprocess import PIPE +from subprocess import Popen + +from ..constants import FPCALC + + +def is_fpcalc_available() -> bool: + """ + Checks if ffmpeg is installed in the system. + :return: True if available, else False. + """ + try: + proc = Popen(["fpcalc", "-v"], stdout=PIPE, stderr=PIPE) + except OSError: + return False + else: + proc.wait() + + +class FPCalc: + """ + Calculates audio fingerprint by passing the audio bytes. + It operates with pipes so no file is created. + """ + def __init__(self, audio: bytes): + """ + Creates the FPCalc object. + :param audio: the audio bytes. + """ + fpcalc = Popen(FPCALC, stdout=PIPE, stdin=PIPE) + out, _ = fpcalc.communicate(audio) + res = out.decode("utf-8") + + duration_pattern = "[^=]\\d+\\n" + fingerprint_pattern = "[^=]*$" + duration = re.search(duration_pattern, res) + fingerprint = re.search(fingerprint_pattern, res) + + self.__duration: int = int(duration.group(0)) + self.__fp: str = str(fingerprint.group(0)) + + def duration(self) -> int: + """ + Obtains the audio duration in seconds. + :return: duration in seconds. + """ + return self.__duration + + def fingerprint(self) -> str: + """ + Obtains the audio fingerprint. + :return: fingerprint in seconds. + """ + return self.__fp diff --git a/YouTubeMDBot/bot.py b/YouTubeMDBot/bot.py new file mode 100755 index 0000000..10b68d9 --- /dev/null +++ b/YouTubeMDBot/bot.py @@ -0,0 +1,37 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from telegram.ext import Updater +from telegram.ext import CommandHandler + +PROGRAM_ARGS = None + + +def main(args: dict): + global PROGRAM_ARGS + PROGRAM_ARGS = args + updater = Updater(args["token"], workers=args["workers"]) + + dispatcher = updater.dispatcher + dispatcher.add_handler(CommandHandler("hello", main)) + + if args["use_webhook"]: + updater.start_webhook(listen=args["ip"], + port=args["port"], + url_path=args["token"], + webhook_url=args["public_url"] + '/' + args["token"]) + else: + updater.start_polling(args["poll_interval"]) + updater.idle() diff --git a/YouTubeMDBot/commands/StartHandler.py b/YouTubeMDBot/commands/StartHandler.py new file mode 100755 index 0000000..8d18c8e --- /dev/null +++ b/YouTubeMDBot/commands/StartHandler.py @@ -0,0 +1,24 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +class StartHandler(object): + def __init__(self): + self._user_data = {} + + def start(self, bot, update): + pass + # TODO diff --git a/YouTubeMDBot/commands/__init__.py b/YouTubeMDBot/commands/__init__.py new file mode 100755 index 0000000..8a858f1 --- /dev/null +++ b/YouTubeMDBot/commands/__init__.py @@ -0,0 +1,15 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . diff --git a/YouTubeMDBot/constants/__init__.py b/YouTubeMDBot/constants/__init__.py new file mode 100755 index 0000000..12af4b1 --- /dev/null +++ b/YouTubeMDBot/constants/__init__.py @@ -0,0 +1,22 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..constants.app_constants import ACOUSTID_KEY +from ..constants.app_constants import FPCALC +from ..constants.app_constants import YDL_CLI_OPTIONS +from ..constants.app_constants import YOUTUBE +from ..constants.app_constants import PROGRAM_ARGS +from ..constants.app_constants import FFMPEG_OPENER +from ..constants.app_constants import FFMPEG_CONVERTER diff --git a/YouTubeMDBot/constants/app_constants.py b/YouTubeMDBot/constants/app_constants.py new file mode 100755 index 0000000..1ef1e70 --- /dev/null +++ b/YouTubeMDBot/constants/app_constants.py @@ -0,0 +1,43 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import os +import sys + +PROGRAM_ARGS = sys.argv +# YouTube DL options +YDL_CLI_OPTIONS = ["youtube-dl", "--format", "bestaudio[ext=m4a]", "--quiet", "--output", + "-"] + +# FPCalc command +FPCALC = ["fpcalc", "-"] + +# API keys +ACOUSTID_KEY = os.environ["ACOUSTID_KEY"] +YOUTUBE = { + "key": os.environ["YOUTUBE_KEY"], + "api": { + "name": "youtube", + "version": "v3" + }, + "endpoint": + "https://www.googleapis.com/youtube/v3/videos?" + "part=id,snippet,contentDetails,statistics&id={0}&key={1}" +} + +# FFmpeg commands +FFMPEG_OPENER = "ffmpeg -i - -f s16le -".split(" ") +FFMPEG_CONVERTER = ["ffmpeg", "-i", "-", "-vn", "-map_metadata", "0", + "-movflags", "use_metadata_tags"] diff --git a/YouTubeMDBot/decorators/__init__.py b/YouTubeMDBot/decorators/__init__.py new file mode 100755 index 0000000..9a87cd2 --- /dev/null +++ b/YouTubeMDBot/decorators/__init__.py @@ -0,0 +1,17 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..decorators.decorators import restricted +from ..decorators.decorators import send_action diff --git a/YouTubeMDBot/decorators/decorators.py b/YouTubeMDBot/decorators/decorators.py new file mode 100755 index 0000000..b3463c4 --- /dev/null +++ b/YouTubeMDBot/decorators/decorators.py @@ -0,0 +1,50 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from functools import wraps + +from ..constants import PROGRAM_ARGS + + +def send_action(action): + """ + Sends an action while processing a command. + :param action: the action to be performed. + :return: itself. + """ + + def decorator(func): + @wraps(func) + def command_func(update, context, *args, **kwargs): + context.bot.send_chat_action(chat_id=update.effective_message.chat_id, action=action) + return func(update, context, *args, **kwargs) + return command_func + + return decorator + + +def restricted(func): + """ + Restricts a specific function to be accessed from non-authorized users. + :param func: function to be wrapped. + :return: itself. + """ + @wraps(func) + def wrapped(update, context, *args, **kwargs): + user_id = update.effective_user.id + if user_id not in PROGRAM_ARGS["admin"]: + return + return func(update, context, *args, **kwargs) + return wrapped diff --git a/YouTubeMDBot/downloader/__init__.py b/YouTubeMDBot/downloader/__init__.py new file mode 100755 index 0000000..89c3cee --- /dev/null +++ b/YouTubeMDBot/downloader/__init__.py @@ -0,0 +1,16 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..downloader.youtube_downloader import YouTubeDownloader diff --git a/YouTubeMDBot/downloader/youtube_downloader.py b/YouTubeMDBot/downloader/youtube_downloader.py new file mode 100755 index 0000000..69c8edb --- /dev/null +++ b/YouTubeMDBot/downloader/youtube_downloader.py @@ -0,0 +1,58 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from io import BytesIO +from typing import Tuple + +from ..constants.app_constants import YDL_CLI_OPTIONS + + +class YouTubeDownloader: + """ + Download a YouTube video directly into memory. + """ + def __init__(self, url: str): + """ + Creates the YouTubeDownloader object. Call "download" for obtaining the video. + :param url: the video URL. + """ + self.__url: str = url + self.__options: list = YDL_CLI_OPTIONS.copy() + self.__options.append(self.__url) + + def download(self) -> Tuple[BytesIO, bytes]: + """ + Downloads the YouTube video directly into memory by using pipes. + :return: a tuple with "BytesIO" and "bytes". + """ + import subprocess + + proc = subprocess.Popen(self.__options, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate() + retcode = proc.returncode + if retcode == 0: + return BytesIO(stdout), stdout + else: + raise RuntimeError("youtube-dl downloader exception - more info: " + + str(stderr.decode("utf-8"))) + + def get_url(self) -> str: + """ + Obtains the video URL. + :return: str with the URL. + """ + return self.__url diff --git a/YouTubeMDBot/errors/EmptyBodyError.py b/YouTubeMDBot/errors/EmptyBodyError.py new file mode 100755 index 0000000..a2e8f81 --- /dev/null +++ b/YouTubeMDBot/errors/EmptyBodyError.py @@ -0,0 +1,23 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +class EmptyBodyError(Exception): + """ + Raises an exception when the body of the json data is empty (e.g.: there is no + video information) + """ + pass diff --git a/YouTubeMDBot/errors/__init__.py b/YouTubeMDBot/errors/__init__.py new file mode 100755 index 0000000..98ce0a2 --- /dev/null +++ b/YouTubeMDBot/errors/__init__.py @@ -0,0 +1,16 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..errors.EmptyBodyError import EmptyBodyError diff --git a/YouTubeMDBot/logging_utils/__init__.py b/YouTubeMDBot/logging_utils/__init__.py new file mode 100755 index 0000000..45c2549 --- /dev/null +++ b/YouTubeMDBot/logging_utils/__init__.py @@ -0,0 +1,17 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..logging_utils.utils import LoggingHandler +from ..logging_utils.utils import setup_logging diff --git a/YouTubeMDBot/logging_utils/utils.py b/YouTubeMDBot/logging_utils/utils.py new file mode 100755 index 0000000..3b6ac76 --- /dev/null +++ b/YouTubeMDBot/logging_utils/utils.py @@ -0,0 +1,164 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import logging + + +def cleanup_old_logs(log_file: str): + """ + Cleans-up the old log files. + :param log_file: log filename that must be cleaned. + """ + import tarfile + import os + + tar_log_filename = log_file + ".tar.gz" + + if os.path.exists(log_file): + if os.path.exists(tar_log_filename): + os.remove(tar_log_filename) + with tarfile.open(tar_log_filename, "w:gz") as tar: + tar.add(log_file, arcname=os.path.basename(log_file)) + tar.close() + os.remove(log_file) + + +def setup_logging(logger_name: str, log_file: str, level=logging.DEBUG, + formatter: str = "%(process)d - %(asctime)s | [%(levelname)s]: %(message)s"): + """ + Creates a new logging which can log to stdout or file. + :param logger_name: the logger name. + :param log_file: log filename. + :param level: logging level. + :param formatter: the logging formatter. + :return: the logging file handler. + """ + from os import path + from os import makedirs + + log_dir = path.dirname(path.abspath(log_file)) + if not path.exists(log_dir): + makedirs(log_dir) + + cleanup_old_logs(log_file) + new_logging = logging.getLogger(logger_name) + logging_formatter = logging.Formatter(formatter) + logging_file_handler = logging.FileHandler(log_file, mode="w") + + logging_file_handler.setFormatter(logging_formatter) + + new_logging.setLevel(level) + new_logging.addHandler(logging_file_handler) + + return logging_file_handler + + +class LoggingHandler: + """ + LoggingHandler singleton class that outputs to multiple logging instances. + """ + + class __LoggingHandler: + def __init__(self, logs: list): + self.__logs = logs + + def debug(self, msg): + for log in self.__logs: + log.debug(msg) + + def info(self, msg): + for log in self.__logs: + log.info(msg) + + def error(self, msg): + for log in self.__logs: + log.error(msg) + + def warning(self, msg): + for log in self.__logs: + log.warning(msg) + + def critical(self, msg): + for log in self.__logs: + log.critical(msg) + + def get_loggers(self) -> list: + return self.__logs + + __instance = None + + def __new__(cls, *args, **kwargs): + """ + Generates a new instance. + :param args: not used. + :param kwargs: "logs" is a list instance that must be provided the first time + this class is created. + :return: the LoggingHandler instance. + """ + if not LoggingHandler.__instance: + logs = kwargs.get("logs") + if not logs or len(logs) == 0: + raise AttributeError( + "At least kwarg \"log\" (a list of the loggers) must be provided") + LoggingHandler.__instance = LoggingHandler.__LoggingHandler(logs) + return LoggingHandler.__instance + + def __getattr__(self, item): + return getattr(self.__instance, item) + + def __setattr__(self, key, value): + return setattr(self.__instance, key, value) + + def debug(self, msg): + """ + Debugs to loggers + :param msg: message to debug + """ + self.__instance.debug(msg) + + def info(self, msg): + """ + Info to loggers + :param msg: message to info + """ + self.__instance.info(msg) + + def error(self, msg): + """ + Error to loggers + :param msg: message to error + """ + self.__instance.error(msg) + + def warning(self, msg): + """ + Warns to loggers + :param msg: message to warn + """ + self.__instance.warning(msg) + + def critical(self, msg): + """ + Critical to loggers + :param msg: message to critical + """ + self.__instance.critical(msg) + + def get_loggers(self) -> list: + """ + Obtains the list of loggers. + :return: the list of loggers. + """ + return self.__instance.get_loggers() diff --git a/YouTubeMDBot/metadata/AudioMetadata.py b/YouTubeMDBot/metadata/AudioMetadata.py new file mode 100755 index 0000000..77754e1 --- /dev/null +++ b/YouTubeMDBot/metadata/AudioMetadata.py @@ -0,0 +1,78 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from mutagen.mp4 import MP4 +from mutagen.mp4 import MP4Cover +from io import BytesIO + + +class AudioMetadata: + """ + Wrapper class for setting the audio metadata to the downloaded YouTube video + object. By using this class, it is possible to set the required information by + using mutagen without having to remember the metadata keys. + """ + def __init__(self, audio: BytesIO): + """ + Generates a new instance. + :param audio: the audio metadata, in BytesIO, in MP4 format. + """ + self._audio = MP4(audio) + self._data = audio + + def set_title(self, title: str): + """ + Sets the audio title. + :param title: the audio title. + """ + self._audio[u"\xa9nam"] = title + + def set_artist(self, artist: str): + """ + Sets the audio artist. + :param artist: the audio artist. + """ + self._audio[u"\xa9ART"] = artist + + def set_album(self, album: str): + """ + Sets the audio album. + :param album: the audio album + """ + self._audio[u"\xa9alb"] = album + + def set_extras(self, extras: list): + """ + Sets the audio extras. + :param extras: a list of extras that will be added to the audio information. + """ + self._audio[u"\xa9cmt"] = '; '.join(map(str, extras)) + + def set_cover(self, cover: bytes): + """ + Sets the audio cover. + :param cover: the audio cover. + """ + mp4_cover = MP4Cover(cover, MP4Cover.FORMAT_JPEG) + self._audio[u"covr"] = [mp4_cover] + + def save(self) -> BytesIO: + """ + Saves the new metadata into the audio file object. + :return: the audio file object with the new metadata. + """ + self._data.seek(0) + self._audio.save(self._data) + return self._data diff --git a/YouTubeMDBot/metadata/MetadataIdentifier.py b/YouTubeMDBot/metadata/MetadataIdentifier.py new file mode 100755 index 0000000..d3396aa --- /dev/null +++ b/YouTubeMDBot/metadata/MetadataIdentifier.py @@ -0,0 +1,175 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +try: + import ujson as json +except ImportError: + import json +import acoustid +import musicbrainzngs + +from ..audio import FPCalc +from ..api import YouTubeAPI +from ..utils import youtube_utils +from ..constants import ACOUSTID_KEY +from ..downloader import YouTubeDownloader + + +class MetadataIdentifier: + """ + Base identifier class. By using the audio data, calculates and generates a + fingerprint for searching across the MusicBrainz database. + + Once the audio has been identified, the available params and information are: + - audio (bytes) + - result (json) + - artist (str) + - title (str) + - release_id (str) + - recording_id (str) + - score (float) + - cover (bytes) + - album (str) + - duration (int) + - youtube_data (bool) + - youtube_id (str) + """ + def __init__(self, audio: bytes): + """ + Generates a new instance of the MetadataIdentifier class. + :param audio: the audio data, in bytes. + """ + self.audio = audio + self.result: json = None + self.artist: str = "" + self.title: str = "" + self.release_id: str = "" + self.recording_id: str = "" + self.score: float = 0.0 + self.cover: bytes = bytes(0) + self.album: str = "" + self.duration: int = 0 + self.youtube_data: bool = False + self.youtube_id: str = "" + + @staticmethod + def _is_valid_result(data: json) -> bool: + """ + Checks whether the obtained result, in json, is valid or not, by checking for + certain keys that must exist. + :param data: the result in json. + :return: 'True' if the result is valid, else 'False'. + """ + if "results" not in data: + return False + elif data["status"] != "ok": + return False + elif len(data["results"]) == 0: + return False + else: + if "recordings" not in data["results"][0]: + return False + else: + return True + + def identify_audio(self) -> bool: + """ + Tries to identify the audio by using the audio fingerprint. If the audio has + been successfully identified, then obtains all the data related to it. + :return: 'True' if the result is valid (the audio was correctly identified), + else 'False'. + """ + fingerprint = FPCalc(self.audio) + data: json = acoustid.lookup(apikey=ACOUSTID_KEY, + fingerprint=fingerprint.fingerprint(), + duration=fingerprint.duration(), + meta="recordings releaseids releasegroups") + self.result = data + is_valid = self._is_valid_result(data) + if is_valid: + for result in data["results"]: + if "recordings" not in result: + break + self.score = result["score"] + for recording in result["recordings"]: + if recording.get("artists"): + names = [artist["name"] for artist in recording["artists"]] + self.artist = "; ".join(names) + else: + self.artist = "Unknown" + self.title = recording["title"] + if recording.get("releasegroups"): + self.release_id = \ + recording["releasegroups"][0]["releases"][0]["id"] + self.album = recording["releasegroups"][0]["title"] + self.cover = musicbrainzngs.get_image_front(self.release_id) + self.recording_id = recording["id"] + self.duration = recording["duration"] + is_valid = True + break + break + return is_valid + + +class YouTubeMetadataIdentifier(MetadataIdentifier): + """ + Identifies YouTube metadata by using MusicBrainz database and YouTube metadata. If + the first identification (MusicBrainz) fails, then fallback to YouTube + identification if the "downloader" was provided. + + Once the audio has been identified, the available params and information are: + - audio (bytes) + - result (json) + - artist (str) + - title (str) + - release_id (str) + - recording_id (str) + - score (float) + - cover (bytes) + - album (str) + - duration (int) + - youtube_data (bool) + - youtube_id (str) + + If "youtube_data" is True, then only audio, title, artist, duration, cover and + youtube_id are available. + """ + def __init__(self, audio: bytes, downloader: YouTubeDownloader = None): + """ + Generates a new instance of the MetadataIdentifier class. + :param audio: the audio data, in bytes. + :param downloader: a downloader object, for obtaining the video information if + MusicBrainz fails. + """ + super().__init__(audio) + self._downloader = downloader + + def identify_audio(self) -> bool: + valid = super().identify_audio() + if not valid: + if self._downloader: + from urllib.request import urlopen + + video_id = youtube_utils.get_yt_video_id(self._downloader.get_url()) + video_data = YouTubeAPI.video_details(video_id) + self.title = video_data.title + self.artist = video_data.artist + self.duration = video_data.duration + self.cover = urlopen(video_data.thumbnail).read() + self.youtube_id = video_data.id + self.youtube_data = True + + valid = True + return valid diff --git a/YouTubeMDBot/metadata/__init__.py b/YouTubeMDBot/metadata/__init__.py new file mode 100755 index 0000000..4abadb9 --- /dev/null +++ b/YouTubeMDBot/metadata/__init__.py @@ -0,0 +1,19 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..metadata.MetadataIdentifier import MetadataIdentifier +from ..metadata.MetadataIdentifier import YouTubeMetadataIdentifier + +from ..metadata.AudioMetadata import AudioMetadata diff --git a/YouTubeMDBot/requirements.txt b/YouTubeMDBot/requirements.txt new file mode 100755 index 0000000..e341c34 --- /dev/null +++ b/YouTubeMDBot/requirements.txt @@ -0,0 +1,8 @@ +mutagen +isodate +google-api-python-client +musicbrainzngs +ujson +youtube_dl +pyacoustid +python-telegram-bot diff --git a/YouTubeMDBot/tests/converter.py b/YouTubeMDBot/tests/converter.py new file mode 100644 index 0000000..17aac27 --- /dev/null +++ b/YouTubeMDBot/tests/converter.py @@ -0,0 +1,36 @@ +import unittest +import mutagen + +from typing import Tuple +from io import BytesIO + +from YouTubeMDBot.tests.tagger import TaggerTest +from YouTubeMDBot.downloader import YouTubeDownloader +from YouTubeMDBot.audio import FFmpegMP3 +from YouTubeMDBot.audio import FFmpegOGG + + +class MyTestCase(TaggerTest): + def find_metadata(self, downloader: YouTubeDownloader) -> Tuple[BytesIO, bytes]: + io, data = super().find_metadata(downloader) + io.seek(0) + mp3 = FFmpegMP3(data=data, bitrate="96k") # downrate + ogg = FFmpegOGG(data=data, bitrate="256k") # uprate + + mp3.convert() + ogg.convert() + + mp3_container = BytesIO(mp3.get_output()) + ogg_container = BytesIO(ogg.get_output()) + + print(mp3.get_extra().decode("utf-8")) + print(ogg.get_extra().decode("utf-8")) + + print(mutagen.File(mp3_container).pprint()) + print(mutagen.File(ogg_container).pprint()) + + return io, data + + +if __name__ == '__main__': + unittest.main() diff --git a/YouTubeMDBot/tests/downloader.py b/YouTubeMDBot/tests/downloader.py new file mode 100755 index 0000000..cb53f74 --- /dev/null +++ b/YouTubeMDBot/tests/downloader.py @@ -0,0 +1,46 @@ +import threading +import unittest +from time import sleep + +from YouTubeMDBot.downloader import YouTubeDownloader + + +class DownloadTest(unittest.TestCase): + _elements = 0 + _max = 0 + _lock = threading.Lock() + + def test_multithread_download(self): + yt1 = YouTubeDownloader(url="https://www.youtube.com/watch?v=Inm-N5rLUSI") + yt2 = YouTubeDownloader(url="https://www.youtube.com/watch?v=-_ZwpOdXXcA") + yt3 = YouTubeDownloader(url="https://www.youtube.com/watch?v=WOGWZD5iT10") + yt4 = YouTubeDownloader(url="https://www.youtube.com/watch?v=9HfoNUjw5u8") + t1 = threading.Thread(target=self.write_to_file, args=(yt1, "v1.m4a",)) + t2 = threading.Thread(target=self.write_to_file, args=(yt2, "v2.m4a",)) + t3 = threading.Thread(target=self.write_to_file, args=(yt3, "v3.m4a",)) + t4 = threading.Thread(target=self.write_to_file, args=(yt4, "v4.m4a",)) + + self._max = 4 + + t1.start() + t2.start() + t3.start() + t4.start() + + while self._elements < self._max: + sleep(1) + + def barrier(self): + with self._lock: + self._elements += 1 + + def write_to_file(self, yt: YouTubeDownloader, name: str): + _, data = yt.download() + print(name + " downloaded") + with open(name, "wb") as f: + f.write(data) + self.barrier() + + +if __name__ == '__main__': + unittest.main() diff --git a/YouTubeMDBot/tests/identifier.py b/YouTubeMDBot/tests/identifier.py new file mode 100755 index 0000000..747f4a0 --- /dev/null +++ b/YouTubeMDBot/tests/identifier.py @@ -0,0 +1,106 @@ +import threading +import unittest +from pprint import pprint +from time import sleep +from time import time +from typing import Tuple +from io import BytesIO + +from YouTubeMDBot.downloader import YouTubeDownloader +from YouTubeMDBot.metadata import YouTubeMetadataIdentifier + + +class IdentifierTest(unittest.TestCase): + lock = threading.Lock() + threads = 0 + max = 0 + song_info = {} + + def test_identification(self): + url = "https://www.youtube.com/watch?v=YQHsXMglC9A" + downloader = YouTubeDownloader(url=url) + audio, data = downloader.download() + with open("hello.m4a", "wb") as song: + song.write(data) + identifier = YouTubeMetadataIdentifier(audio=data, downloader=downloader) + + valid = identifier.identify_audio() + assert valid + print("{0} by {1} - score: {2} / 1\n" + "\thttps://musicbrainz.org/recording/{3}\n" + "\thttps://musicbrainz.org/release/{4}\n\n" + .format(identifier.title, identifier.artist, + identifier.score, + identifier.recording_id, identifier.release_id)) + with open("cover.jpg", "wb") as cover: + cover.write(identifier.cover) + + def test_multiple_download_identification(self): + yt1 = YouTubeDownloader(url="https://www.youtube.com/watch?v=Inm-N5rLUSI") + yt2 = YouTubeDownloader(url="https://www.youtube.com/watch?v=-_ZwpOdXXcA") + yt3 = YouTubeDownloader(url="https://www.youtube.com/watch?v=WOGWZD5iT10") + yt4 = YouTubeDownloader(url="https://www.youtube.com/watch?v=GfKV9KaNJXc") + yt5 = YouTubeDownloader(url="https://www.youtube.com/watch?v=DiItGE3eAyQ") + yt6 = YouTubeDownloader(url="https://www.youtube.com/watch?v=GuZzuQvv7uc") + + t1 = threading.Thread(target=self.find_metadata, args=(yt1,)) + t2 = threading.Thread(target=self.find_metadata, args=(yt2,)) + t3 = threading.Thread(target=self.find_metadata, args=(yt3,)) + t4 = threading.Thread(target=self.find_metadata, args=(yt4,)) + t5 = threading.Thread(target=self.find_metadata, args=(yt5,)) + t6 = threading.Thread(target=self.find_metadata, args=(yt6,)) + + self.max = 6 + + t1.start() + t2.start() + t3.start() + t4.start() + t5.start() + t6.start() + + while self.threads < self.max: + sleep(1) + + # pprint(self.song_info) + pprint("Finished") + + def barrier(self): + with self.lock: + self.threads += 1 + + def getThreads(self): + with self.lock: + return self.threads + + def find_metadata(self, downloader: YouTubeDownloader) -> Tuple[BytesIO, bytes]: + st_dl_t = time() + io, data = downloader.download() + f_dl_t = time() + print("Downloaded {} - elapsed time: {:.1f}s".format(downloader.get_url(), + f_dl_t - st_dl_t)) + identifier = YouTubeMetadataIdentifier(audio=data, downloader=downloader) + valid = identifier.identify_audio() + assert valid + self.song_info[downloader.get_url()] = { + "title": identifier.title, + "artist": identifier.artist, + "cover": identifier.cover + } + if not identifier.youtube_data: + self.song_info[downloader.get_url()]["score"] = identifier.score + self.song_info[downloader.get_url()]["record_id"] = \ + "https://musicbrainz.org/recording/{0}".format(identifier.recording_id) + self.song_info[downloader.get_url()]["release_id"] = \ + "https://musicbrainz.org/release/{0}".format(identifier.release_id) + self.song_info[downloader.get_url()]["album"] = identifier.album + else: + self.song_info[downloader.get_url()]["duration"] = identifier.duration + self.song_info[downloader.get_url()]["id"] = identifier.youtube_id + self.song_info[downloader.get_url()]["youtube_data"] = True + self.barrier() + return io, data + + +if __name__ == '__main__': + unittest.main() diff --git a/YouTubeMDBot/tests/song_search.py b/YouTubeMDBot/tests/song_search.py new file mode 100755 index 0000000..c4afadb --- /dev/null +++ b/YouTubeMDBot/tests/song_search.py @@ -0,0 +1,25 @@ +import unittest + +from YouTubeMDBot.api import YouTubeAPI +from YouTubeMDBot.api import YouTubeVideoData + + +class TestSearch(unittest.TestCase): + def test_search(self): + s = YouTubeAPI() + search: dict = s.search(term="test") + data = YouTubeVideoData(data=search, ignore_errors=True) + print("Title: {0}\n" + "Artist: {1}\n" + "Thumbnail: {2}\n" + "Duration: {3}\n" + "Views: {4}\n" + "Likes: {5}\n" + "Dislikes: {6}\n" + "Id: {7}".format(data.title, data.artist, data.thumbnail, + data.duration, data.views, data.likes, + data.dislikes, data.id)) + + +if __name__ == '__main__': + unittest.main() diff --git a/YouTubeMDBot/tests/tagger.py b/YouTubeMDBot/tests/tagger.py new file mode 100755 index 0000000..7d8ed02 --- /dev/null +++ b/YouTubeMDBot/tests/tagger.py @@ -0,0 +1,44 @@ +import unittest +import mutagen + +from typing import Tuple +from io import BytesIO + +from YouTubeMDBot.tests.identifier import IdentifierTest +from YouTubeMDBot.downloader import YouTubeDownloader +from YouTubeMDBot.metadata import AudioMetadata +from YouTubeMDBot.utils import youtube_utils + + +class TaggerTest(IdentifierTest): + def find_metadata(self, downloader: YouTubeDownloader) -> Tuple[BytesIO, bytes]: + io, data = super().find_metadata(downloader) + tagger = AudioMetadata(io) + url = downloader.get_url() + + tagger.set_title(super().song_info[url]["title"]) + tagger.set_artist(super().song_info[url]["artist"]) + tagger.set_cover(super().song_info[url]["cover"]) + extra = ["YouTube URL: " + url] + if not super().song_info[url].get("youtube_data"): + tagger.set_album(super().song_info[url]["album"]) + extra.append("MusicBrainz Record ID: " + super().song_info[url][ + "record_id"]) + extra.append("MusicBrainz Release ID: " + super().song_info[url][ + "release_id"]) + tagger.set_extras(extra) + else: + tagger.set_extras(["YouTube ID: {}".format(super().song_info[url]["id"])]) + yid = youtube_utils.get_yt_video_id(url) + rs = tagger.save() + rs.seek(0) + print(mutagen.File(rs).pprint()) + rs.seek(0) + with open(yid + ".m4a", "wb") as f: + f.write(rs.read()) + rs.seek(0) + return rs, rs.read() + + +if __name__ == '__main__': + unittest.main() diff --git a/YouTubeMDBot/utils/__init__.py b/YouTubeMDBot/utils/__init__.py new file mode 100755 index 0000000..9fe1f3e --- /dev/null +++ b/YouTubeMDBot/utils/__init__.py @@ -0,0 +1,16 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from ..utils.youtube_utils import get_yt_video_id diff --git a/YouTubeMDBot/utils/youtube_utils.py b/YouTubeMDBot/utils/youtube_utils.py new file mode 100755 index 0000000..7d66274 --- /dev/null +++ b/YouTubeMDBot/utils/youtube_utils.py @@ -0,0 +1,52 @@ +# YouTubeMDBot +# Copyright (C) 2019 - Javinator9889 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +def get_yt_video_id(url: str) -> str: + # initial version: http://stackoverflow.com/a/7936523/617185 \ + # by Mikhail Kashkin(http://stackoverflow.com/users/85739/mikhail-kashkin) + """Returns Video_ID extracting from the given url of Youtube + + Examples of URLs: + Valid: + 'http://youtu.be/_lOT2p_FCvA', + 'www.youtube.com/watch?v=_lOT2p_FCvA&feature=feedu', + 'http://www.youtube.com/embed/_lOT2p_FCvA', + 'http://www.youtube.com/v/_lOT2p_FCvA?version=3&hl=en_US', + 'https://www.youtube.com/watch?v=rTHlyTphWP0&index=6&list=PLjeDyYvG6-40qawYNR4juzvSOg-ezZ2a6', + 'youtube.com/watch?v=_lOT2p_FCvA', + + Invalid: + 'youtu.be/watch?v=_lOT2p_FCvA', + """ + + from urllib.parse import urlparse + from urllib.parse import parse_qs + + if url.startswith(('youtu', 'www')): + url = 'http://' + url + + query = urlparse(url) + + if 'youtube' in query.hostname: + if query.path == '/watch': + return parse_qs(query.query)['v'][0] + elif query.path.startswith(('/embed/', '/v/')): + return query.path.split('/')[2] + elif 'youtu.be' in query.hostname: + return query.path[1:] + else: + raise ValueError