diff --git a/.drone.yml b/.drone.yml index 21b3017..a170d75 100644 --- a/.drone.yml +++ b/.drone.yml @@ -24,31 +24,14 @@ steps: commands: - poetry run invoke lint - - name: start worker - image: thetadev256/ucast-dev - volumes: - - name: cache - path: /root/.cache - environment: - UCAST_REDIS_HOST: redis - commands: - - poetry run invoke worker - detach: true - - name: test image: thetadev256/ucast-dev volumes: - name: cache path: /root/.cache - environment: - UCAST_REDIS_HOST: redis commands: - poetry run invoke test -services: - - name: redis - image: redis:alpine - volumes: - name: cache temp: { } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..71f453a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 88 + +[{Makefile,*.go}] +indent_style = tab + +[*.{json,md,rst,ini,yml,yaml}] +indent_size = 2 diff --git a/deploy/docker-compose_develop.yml b/deploy/docker-compose_develop.yml index 33388e4..6d05622 100644 --- a/deploy/docker-compose_develop.yml +++ b/deploy/docker-compose_develop.yml @@ -1,7 +1,14 @@ version: "3" services: redis: - container_name: ucast-redis + container_name: redis image: redis:alpine ports: - "127.0.0.1:6379:6379" + + rq-dashboard: + image: eoranged/rq-dashboard + ports: + - "127.0.0.1:9181:9181" + environment: + RQ_DASHBOARD_REDIS_URL: "redis://redis:6379" diff --git a/poetry.lock b/poetry.lock index 9d928f5..2138b01 100644 --- a/poetry.lock +++ b/poetry.lock @@ -128,14 +128,14 @@ Pillow = "*" [[package]] name = "coverage" -version = "6.3.3" +version = "6.4" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -tomli = {version = "*", optional = true, markers = "extra == \"toml\""} +tomli = {version = "*", optional = true, markers = "python_version < \"3.11\" and extra == \"toml\""} [package.extras] toml = ["tomli"] @@ -202,25 +202,26 @@ python-versions = ">=3.7" django = ">=2.2" [[package]] -name = "django-rq" -version = "2.5.1" -description = "An app that provides django integration for RQ (Redis Queue)" -category = "main" +name = "fakeredis" +version = "1.7.5" +description = "Fake implementation of redis API for testing purposes." +category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" [package.dependencies] -django = ">=2.0" -redis = ">=3" -rq = ">=1.2" +packaging = "*" +redis = "<=4.3.1" +six = ">=1.12" +sortedcontainers = "*" [package.extras] -sentry = ["raven (>=6.1.0)"] -testing = ["mock (>=2.0.0)"] +aioredis = ["aioredis"] +lua = ["lupa"] [[package]] name = "feedparser" -version = "6.0.9" +version = "6.0.10" description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" category = "main" optional = false @@ -306,6 +307,19 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "mock" +version = "4.0.3" +description = "Rolling backport of unittest.mock for all Pythons" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +build = ["twine", "wheel", "blurb"] +docs = ["sphinx"] +test = ["pytest (<5.4)", "pytest-cov"] + [[package]] name = "mutagen" version = "1.45.1" @@ -487,6 +501,20 @@ pytest = ">=5.4.0" docs = ["sphinx", "sphinx-rtd-theme"] testing = ["django", "django-configurations (>=2.0)"] +[[package]] +name = "pytest-mock" +version = "3.7.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "tox", "pytest-asyncio"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -615,6 +643,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "sqlparse" version = "0.4.2" @@ -729,7 +765,7 @@ websockets = "*" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "312ee264a4f1ed8ef9160046b18f3b76a23af638be5effb9f9feb78b25d05aae" +content-hash = "ad3a5ecd6fc1152dfdfda51ed1e401ec11a048661a04f42985c15bc28e8eda9f" [metadata.files] asgiref = [ @@ -921,47 +957,47 @@ colorthief = [ {file = "colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221"}, ] coverage = [ - {file = "coverage-6.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df32ee0f4935a101e4b9a5f07b617d884a531ed5666671ff6ac66d2e8e8246d8"}, - {file = "coverage-6.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75b5dbffc334e0beb4f6c503fb95e6d422770fd2d1b40a64898ea26d6c02742d"}, - {file = "coverage-6.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:114944e6061b68a801c5da5427b9173a0dd9d32cd5fcc18a13de90352843737d"}, - {file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab88a01cd180b5640ccc9c47232e31924d5f9967ab7edd7e5c91c68eee47a69"}, - {file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad8f9068f5972a46d50fe5f32c09d6ee11da69c560fcb1b4c3baea246ca4109b"}, - {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4cd696aa712e6cd16898d63cf66139dc70d998f8121ab558f0e1936396dbc579"}, - {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c1a9942e282cc9d3ed522cd3e3cab081149b27ea3bda72d6f61f84eaf88c1a63"}, - {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c06455121a089252b5943ea682187a4e0a5cf0a3fb980eb8e7ce394b144430a9"}, - {file = "coverage-6.3.3-cp310-cp310-win32.whl", hash = "sha256:cb5311d6ccbd22578c80028c5e292a7ab9adb91bd62c1982087fad75abe2e63d"}, - {file = "coverage-6.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:6d4a6f30f611e657495cc81a07ff7aa8cd949144e7667c5d3e680d73ba7a70e4"}, - {file = "coverage-6.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79bf405432428e989cad7b8bc60581963238f7645ae8a404f5dce90236cc0293"}, - {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:338c417613f15596af9eb7a39353b60abec9d8ce1080aedba5ecee6a5d85f8d3"}, - {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db094a6a4ae6329ed322a8973f83630b12715654c197dd392410400a5bfa1a73"}, - {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1414e8b124611bf4df8d77215bd32cba6e3425da8ce9c1f1046149615e3a9a31"}, - {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:93b16b08f94c92cab88073ffd185070cdcb29f1b98df8b28e6649145b7f2c90d"}, - {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fbc86ae8cc129c801e7baaafe3addf3c8d49c9c1597c44bdf2d78139707c3c62"}, - {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b5ba058610e8289a07db2a57bce45a1793ec0d3d11db28c047aae2aa1a832572"}, - {file = "coverage-6.3.3-cp37-cp37m-win32.whl", hash = "sha256:8329635c0781927a2c6ae068461e19674c564e05b86736ab8eb29c420ee7dc20"}, - {file = "coverage-6.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e5af1feee71099ae2e3b086ec04f57f9950e1be9ecf6c420696fea7977b84738"}, - {file = "coverage-6.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e814a4a5a1d95223b08cdb0f4f57029e8eab22ffdbae2f97107aeef28554517e"}, - {file = "coverage-6.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f4fbf3633cb0713437291b8848634ea97f89c7e849c2be17a665611e433f53"}, - {file = "coverage-6.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3401b0d2ed9f726fadbfa35102e00d1b3547b73772a1de5508ef3bdbcb36afe7"}, - {file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8586b177b4407f988731eb7f41967415b2197f35e2a6ee1a9b9b561f6323c8e9"}, - {file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:892e7fe32191960da559a14536768a62e83e87bbb867e1b9c643e7e0fbce2579"}, - {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:afb03f981fadb5aed1ac6e3dd34f0488e1a0875623d557b6fad09b97a942b38a"}, - {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cbe91bc84be4e5ef0b1480d15c7b18e29c73bdfa33e07d3725da7d18e1b0aff2"}, - {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:91502bf27cbd5c83c95cfea291ef387469f2387508645602e1ca0fd8a4ba7548"}, - {file = "coverage-6.3.3-cp38-cp38-win32.whl", hash = "sha256:c488db059848702aff30aa1d90ef87928d4e72e4f00717343800546fdbff0a94"}, - {file = "coverage-6.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6534fcdfb5c503affb6b1130db7b5bfc8a0f77fa34880146f7a5c117987d0"}, - {file = "coverage-6.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc692c9ee18f0dd3214843779ba6b275ee4bb9b9a5745ba64265bce911aefd1a"}, - {file = "coverage-6.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:462105283de203df8de58a68c1bb4ba2a8a164097c2379f664fa81d6baf94b81"}, - {file = "coverage-6.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc972d829ad5ef4d4c5fcabd2bbe2add84ce8236f64ba1c0c72185da3a273130"}, - {file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06f54765cdbce99901871d50fe9f41d58213f18e98b170a30ca34f47de7dd5e8"}, - {file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7835f76a081787f0ca62a53504361b3869840a1620049b56d803a8cb3a9eeea3"}, - {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6f5fee77ec3384b934797f1873758f796dfb4f167e1296dc00f8b2e023ce6ee9"}, - {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:baa8be8aba3dd1e976e68677be68a960a633a6d44c325757aefaa4d66175050f"}, - {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d06380e777dd6b35ee936f333d55b53dc4a8271036ff884c909cf6e94be8b6c"}, - {file = "coverage-6.3.3-cp39-cp39-win32.whl", hash = "sha256:f8cabc5fd0091976ab7b020f5708335033e422de25e20ddf9416bdce2b7e07d8"}, - {file = "coverage-6.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c9441d57b0963cf8340268ad62fc83de61f1613034b79c2b1053046af0c5284"}, - {file = "coverage-6.3.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:d522f1dc49127eab0bfbba4e90fa068ecff0899bbf61bf4065c790ddd6c177fe"}, - {file = "coverage-6.3.3.tar.gz", hash = "sha256:2781c43bffbbec2b8867376d4d61916f5e9c4cc168232528562a61d1b4b01879"}, + {file = "coverage-6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50ed480b798febce113709846b11f5d5ed1e529c88d8ae92f707806c50297abf"}, + {file = "coverage-6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:26f8f92699756cb7af2b30720de0c5bb8d028e923a95b6d0c891088025a1ac8f"}, + {file = "coverage-6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60c2147921da7f4d2d04f570e1838db32b95c5509d248f3fe6417e91437eaf41"}, + {file = "coverage-6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:750e13834b597eeb8ae6e72aa58d1d831b96beec5ad1d04479ae3772373a8088"}, + {file = "coverage-6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af5b9ee0fc146e907aa0f5fb858c3b3da9199d78b7bb2c9973d95550bd40f701"}, + {file = "coverage-6.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a022394996419142b33a0cf7274cb444c01d2bb123727c4bb0b9acabcb515dea"}, + {file = "coverage-6.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5a78cf2c43b13aa6b56003707c5203f28585944c277c1f3f109c7b041b16bd39"}, + {file = "coverage-6.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9229d074e097f21dfe0643d9d0140ee7433814b3f0fc3706b4abffd1e3038632"}, + {file = "coverage-6.4-cp310-cp310-win32.whl", hash = "sha256:fb45fe08e1abc64eb836d187b20a59172053999823f7f6ef4f18a819c44ba16f"}, + {file = "coverage-6.4-cp310-cp310-win_amd64.whl", hash = "sha256:3cfd07c5889ddb96a401449109a8b97a165be9d67077df6802f59708bfb07720"}, + {file = "coverage-6.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:03014a74023abaf5a591eeeaf1ac66a73d54eba178ff4cb1fa0c0a44aae70383"}, + {file = "coverage-6.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c82f2cd69c71698152e943f4a5a6b83a3ab1db73b88f6e769fabc86074c3b08"}, + {file = "coverage-6.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b546cf2b1974ddc2cb222a109b37c6ed1778b9be7e6b0c0bc0cf0438d9e45a6"}, + {file = "coverage-6.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc173f1ce9ffb16b299f51c9ce53f66a62f4d975abe5640e976904066f3c835d"}, + {file = "coverage-6.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c53ad261dfc8695062fc8811ac7c162bd6096a05a19f26097f411bdf5747aee7"}, + {file = "coverage-6.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:eef5292b60b6de753d6e7f2d128d5841c7915fb1e3321c3a1fe6acfe76c38052"}, + {file = "coverage-6.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:543e172ce4c0de533fa892034cce260467b213c0ea8e39da2f65f9a477425211"}, + {file = "coverage-6.4-cp37-cp37m-win32.whl", hash = "sha256:00c8544510f3c98476bbd58201ac2b150ffbcce46a8c3e4fb89ebf01998f806a"}, + {file = "coverage-6.4-cp37-cp37m-win_amd64.whl", hash = "sha256:b84ab65444dcc68d761e95d4d70f3cfd347ceca5a029f2ffec37d4f124f61311"}, + {file = "coverage-6.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d548edacbf16a8276af13063a2b0669d58bbcfca7c55a255f84aac2870786a61"}, + {file = "coverage-6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:033ebec282793bd9eb988d0271c211e58442c31077976c19c442e24d827d356f"}, + {file = "coverage-6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742fb8b43835078dd7496c3c25a1ec8d15351df49fb0037bffb4754291ef30ce"}, + {file = "coverage-6.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55fae115ef9f67934e9f1103c9ba826b4c690e4c5bcf94482b8b2398311bf9c"}, + {file = "coverage-6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd698341626f3c77784858427bad0cdd54a713115b423d22ac83a28303d1d95"}, + {file = "coverage-6.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62d382f7d77eeeaff14b30516b17bcbe80f645f5cf02bb755baac376591c653c"}, + {file = "coverage-6.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:016d7f5cf1c8c84f533a3c1f8f36126fbe00b2ec0ccca47cc5731c3723d327c6"}, + {file = "coverage-6.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:69432946f154c6add0e9ede03cc43b96e2ef2733110a77444823c053b1ff5166"}, + {file = "coverage-6.4-cp38-cp38-win32.whl", hash = "sha256:83bd142cdec5e4a5c4ca1d4ff6fa807d28460f9db919f9f6a31babaaa8b88426"}, + {file = "coverage-6.4-cp38-cp38-win_amd64.whl", hash = "sha256:4002f9e8c1f286e986fe96ec58742b93484195defc01d5cc7809b8f7acb5ece3"}, + {file = "coverage-6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e4f52c272fdc82e7c65ff3f17a7179bc5f710ebc8ce8a5cadac81215e8326740"}, + {file = "coverage-6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b5578efe4038be02d76c344007b13119b2b20acd009a88dde8adec2de4f630b5"}, + {file = "coverage-6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8099ea680201c2221f8468c372198ceba9338a5fec0e940111962b03b3f716a"}, + {file = "coverage-6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a00441f5ea4504f5abbc047589d09e0dc33eb447dc45a1a527c8b74bfdd32c65"}, + {file = "coverage-6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e76bd16f0e31bc2b07e0fb1379551fcd40daf8cdf7e24f31a29e442878a827c"}, + {file = "coverage-6.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8d2e80dd3438e93b19e1223a9850fa65425e77f2607a364b6fd134fcd52dc9df"}, + {file = "coverage-6.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:341e9c2008c481c5c72d0e0dbf64980a4b2238631a7f9780b0fe2e95755fb018"}, + {file = "coverage-6.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:21e6686a95025927775ac501e74f5940cdf6fe052292f3a3f7349b0abae6d00f"}, + {file = "coverage-6.4-cp39-cp39-win32.whl", hash = "sha256:968ed5407f9460bd5a591cefd1388cc00a8f5099de9e76234655ae48cfdbe2c3"}, + {file = "coverage-6.4-cp39-cp39-win_amd64.whl", hash = "sha256:e35217031e4b534b09f9b9a5841b9344a30a6357627761d4218818b865d45055"}, + {file = "coverage-6.4-pp36.pp37.pp38-none-any.whl", hash = "sha256:e637ae0b7b481905358624ef2e81d7fb0b1af55f5ff99f9ba05442a444b11e45"}, + {file = "coverage-6.4.tar.gz", hash = "sha256:727dafd7f67a6e1cad808dc884bd9c5a2f6ef1f8f6d2f22b37b96cb0080d4f49"}, ] croniter = [ {file = "croniter-1.3.5-py2.py3-none-any.whl", hash = "sha256:4f72faca42c00beb6e30907f1315145f43dfbe5ec0ad4ada24b4c0d57b86a33a"}, @@ -983,13 +1019,13 @@ django-bulma = [ {file = "django-bulma-0.8.3.tar.gz", hash = "sha256:b794b4e64f482de77f376451f7cd8b3c8448eb68e5a24c51b9190625a08b0b30"}, {file = "django_bulma-0.8.3-py3-none-any.whl", hash = "sha256:0ef6e5c171c2a32010e724a8be61ba6cd0e55ebbd242cf6780560518483c4d00"}, ] -django-rq = [ - {file = "django-rq-2.5.1.tar.gz", hash = "sha256:f08486602664d73a6e335872c868d79663e380247e6307496d01b8fa770fefd8"}, - {file = "django_rq-2.5.1-py2.py3-none-any.whl", hash = "sha256:7be1e10e7091555f9f36edf100b0dbb205ea2b98683d74443d2bdf3c6649a03f"}, +fakeredis = [ + {file = "fakeredis-1.7.5-py3-none-any.whl", hash = "sha256:c4ca2be686e7e7637756ccc7dcad8472a5e4866b065431107d7a4b7a250d4e6f"}, + {file = "fakeredis-1.7.5.tar.gz", hash = "sha256:49375c630981dd4045d9a92e2709fcd4476c91f927e0228493eefa625e705133"}, ] feedparser = [ - {file = "feedparser-6.0.9-py3-none-any.whl", hash = "sha256:a522b2b81f3914a74ae44161a341940f74811bd29be5b4c2a689e6e6be51cd39"}, - {file = "feedparser-6.0.9.tar.gz", hash = "sha256:dad42e7beaec55f99c08b2b0cf7288bc7cfd24b6f72c8ef85478bcb55648cd42"}, + {file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"}, + {file = "feedparser-6.0.10.tar.gz", hash = "sha256:27da485f4637ce7163cdeab13a80312b93b7d0c1b775bef4a47629a3110bca51"}, ] filelock = [ {file = "filelock-3.7.0-py3-none-any.whl", hash = "sha256:c7b5fdb219b398a5b28c8e4c1893ef5f98ece6a38c6ab2c22e26ec161556fed6"}, @@ -1025,6 +1061,10 @@ invoke = [ {file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"}, {file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"}, ] +mock = [ + {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, + {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, +] mutagen = [ {file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, {file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"}, @@ -1162,6 +1202,10 @@ pytest-django = [ {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, ] +pytest-mock = [ + {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, + {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, +] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -1235,6 +1279,10 @@ six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +sortedcontainers = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] sqlparse = [ {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, diff --git a/pyproject.toml b/pyproject.toml index d2f33af..a978110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ psycopg2 = "^2.9.3" mysqlclient = "^2.1.0" python-slugify = "^6.1.2" mutagen = "^1.45.1" -django-rq = "^2.5.1" +rq = "^1.10.1" rq-scheduler = "^0.11.0" [tool.poetry.dev-dependencies] @@ -36,6 +36,8 @@ invoke = "^1.7.0" pytest-django = "^4.5.2" pre-commit = "^2.19.0" honcho = "^1.1.0" +pytest-mock = "^3.7.0" +fakeredis = "^1.7.5" [tool.poetry.scripts] "ucast-manage" = "ucast_project.manage:main" diff --git a/ucast/__init__.py b/ucast/__init__.py index 024a442..f102a9c 100644 --- a/ucast/__init__.py +++ b/ucast/__init__.py @@ -1,3 +1 @@ __version__ = "0.0.1" - -default_app_config = "ucast.apps.UcastConfig" diff --git a/ucast/management/commands/rqenqueue.py b/ucast/management/commands/rqenqueue.py new file mode 100644 index 0000000..b55781b --- /dev/null +++ b/ucast/management/commands/rqenqueue.py @@ -0,0 +1,34 @@ +""" +Based on the django-rq package by Selwin Ong (MIT License) +https://github.com/rq/django-rq +""" + +from django.core.management.base import BaseCommand + +from ucast import queue + + +class Command(BaseCommand): + """Queue a function with the given arguments.""" + + help = __doc__ + args = "" + + def add_arguments(self, parser): + parser.add_argument( + "--timeout", "-t", type=int, dest="timeout", help="A timeout in seconds" + ) + + parser.add_argument("args", nargs="*") + + def handle(self, *args, **options): + """ + Queues the function given with the first argument with the + parameters given with the rest of the argument list. + """ + verbosity = int(options.get("verbosity", 1)) + timeout = options.get("timeout") + q = queue.get_queue() + job = q.enqueue_call(args[0], args=args[1:], timeout=timeout) + if verbosity: + print("Job %s created" % job.id) diff --git a/ucast/management/commands/rqscheduler.py b/ucast/management/commands/rqscheduler.py index 6a357ff..db0d25d 100644 --- a/ucast/management/commands/rqscheduler.py +++ b/ucast/management/commands/rqscheduler.py @@ -1,11 +1,58 @@ -from django_rq.management.commands import rqscheduler +""" +Based on the django-rq package by Selwin Ong (MIT License) +https://github.com/rq/django-rq +""" +import os + +from django.core.management.base import BaseCommand +from rq_scheduler.utils import setup_loghandlers + +from ucast import queue from ucast.tasks import schedule -class Command(rqscheduler.Command): - def handle(self, *args, **kwargs): - print("Starting ucast scheduler") +class Command(BaseCommand): + """Runs RQ Scheduler""" + + help = __doc__ + + def add_arguments(self, parser): + parser.add_argument( + "--pid", + action="store", + dest="pid", + default=None, + help="PID file to write the scheduler`s pid into", + ) + parser.add_argument( + "--interval", + "-i", + type=int, + dest="interval", + default=60, + help="""How often the scheduler checks for new jobs to add to the + queue (in seconds).""", + ) + + def handle(self, *args, **options): schedule.clear_scheduled_jobs() schedule.register_scheduled_jobs() - super(Command, self).handle(*args, **kwargs) + + pid = options.get("pid") + if pid: + with open(os.path.expanduser(pid), "w") as fp: + fp.write(str(os.getpid())) + + # Verbosity is defined by default in BaseCommand for all commands + verbosity = options.get("verbosity") + if verbosity >= 2: + level = "DEBUG" + elif verbosity == 0: + level = "WARNING" + else: + level = "INFO" + setup_loghandlers(level) + + scheduler = queue.get_scheduler(options.get("interval")) + scheduler.run() diff --git a/ucast/management/commands/rqstats.py b/ucast/management/commands/rqstats.py new file mode 100644 index 0000000..f993864 --- /dev/null +++ b/ucast/management/commands/rqstats.py @@ -0,0 +1,121 @@ +""" +Based on the django-rq package by Selwin Ong (MIT License) +https://github.com/rq/django-rq +""" + +import time + +import click +from django.core.management.base import BaseCommand + +from ucast import queue + + +class Command(BaseCommand): + """Print RQ statistics""" + + help = __doc__ + + def add_arguments(self, parser): + parser.add_argument( + "-j", + "--json", + action="store_true", + dest="json", + help="Output statistics as JSON", + ) + + parser.add_argument( + "-y", + "--yaml", + action="store_true", + dest="yaml", + help="Output statistics as YAML", + ) + + parser.add_argument( + "-i", + "--interval", + dest="interval", + type=float, + help="Poll statistics every N seconds", + ) + + def _print_separator(self): + try: + click.echo(self._separator) + except AttributeError: + self._separator = "-" * self.table_width + click.echo(self._separator) + + def _print_stats_dashboard(self, statistics): + if self.interval: + click.clear() + + click.echo() + click.echo("Django RQ CLI Dashboard") + click.echo() + self._print_separator() + + # Header + click.echo( + """| %-15s|%10s |%10s |%10s |%10s |%10s |""" + % ("Name", "Queued", "Active", "Deferred", "Finished", "Workers") + ) + + self._print_separator() + + click.echo( + """| %-15s|%10s |%10s |%10s |%10s |%10s |""" + % ( + statistics["name"], + statistics["jobs"], + statistics["started_jobs"], + statistics["deferred_jobs"], + statistics["finished_jobs"], + statistics["workers"], + ) + ) + + self._print_separator() + + if self.interval: + click.echo() + click.echo("Press 'Ctrl+c' to quit") + + def handle(self, *args, **options): + + if options.get("json"): + import json + + click.echo(json.dumps(queue.get_statistics())) + return + + if options.get("yaml"): + try: + import yaml + except ImportError: + click.echo("Aborting. LibYAML is not installed.") + return + # Disable YAML alias + yaml.Dumper.ignore_aliases = lambda *args: True + click.echo(yaml.dump(queue.get_statistics(), default_flow_style=False)) + return + + self.interval = options.get("interval") + + # Arbitrary + self.table_width = 78 + + # Do not continuously poll + if not self.interval: + self._print_stats_dashboard(queue.get_statistics()) + return + + # Abuse clicks to 'live' render CLI dashboard + try: + while True: + self._print_stats_dashboard(queue.get_statistics()) + time.sleep(self.interval) + except KeyboardInterrupt: + pass diff --git a/ucast/management/commands/rqworker.py b/ucast/management/commands/rqworker.py new file mode 100644 index 0000000..2f391b5 --- /dev/null +++ b/ucast/management/commands/rqworker.py @@ -0,0 +1,103 @@ +""" +Based on the django-rq package by Selwin Ong (MIT License) +https://github.com/rq/django-rq +""" + +import os +import sys + +from django.core.management.base import BaseCommand +from django.db import connections +from redis.exceptions import ConnectionError +from rq import use_connection +from rq.logutils import setup_loghandlers + +from ucast import queue + + +def reset_db_connections(): + for c in connections.all(): + c.close() + + +class Command(BaseCommand): + """Runs RQ worker""" + + help = __doc__ + + def add_arguments(self, parser): + parser.add_argument( + "--pid", + action="store", + dest="pid", + default=None, + help="PID file to write the worker`s pid into", + ) + parser.add_argument( + "--burst", + action="store_true", + dest="burst", + default=False, + help="Run worker in burst mode", + ) + parser.add_argument( + "--with-scheduler", + action="store_true", + dest="with_scheduler", + default=False, + help="Run worker with scheduler enabled", + ) + parser.add_argument( + "--name", + action="store", + dest="name", + default=None, + help="Name of the worker", + ) + parser.add_argument( + "--worker-ttl", + action="store", + type=int, + dest="worker_ttl", + default=420, + help="Default worker timeout to be used", + ) + + def handle(self, *args, **options): + pid = options.get("pid") + if pid: + with open(os.path.expanduser(pid), "w") as fp: + fp.write(str(os.getpid())) + + # Verbosity is defined by default in BaseCommand for all commands + verbosity = options.get("verbosity") + if verbosity >= 2: + level = "DEBUG" + elif verbosity == 0: + level = "WARNING" + else: + level = "INFO" + setup_loghandlers(level) + + try: + # Instantiate a worker + worker_kwargs = { + "name": options["name"], + "default_worker_ttl": options["worker_ttl"], + } + w = queue.get_worker(**worker_kwargs) + + # Call use_connection to push the redis connection into LocalStack + # without this, jobs using RQ's get_current_job() will fail + use_connection(w.connection) + # Close any opened DB connection before any fork + reset_db_connections() + + w.work( + burst=options.get("burst", False), + with_scheduler=options.get("with_scheduler", False), + logging_level=level, + ) + except ConnectionError as e: + self.stderr.write(str(e)) + sys.exit(1) diff --git a/ucast/migrations/0001_initial.py b/ucast/migrations/0001_initial.py index ed82961..12d2a90 100644 --- a/ucast/migrations/0001_initial.py +++ b/ucast/migrations/0001_initial.py @@ -25,6 +25,7 @@ class Migration(migrations.Migration): ("skip_livestreams", models.BooleanField(default=True)), ("skip_shorts", models.BooleanField(default=True)), ("keep_videos", models.IntegerField(default=None, null=True)), + ("avatar_url", models.CharField(max_length=250, null=True)), ], ), migrations.CreateModel( diff --git a/ucast/models.py b/ucast/models.py index f64f565..1079281 100644 --- a/ucast/models.py +++ b/ucast/models.py @@ -36,11 +36,18 @@ class Channel(models.Model): skip_livestreams = models.BooleanField(default=True) skip_shorts = models.BooleanField(default=True) keep_videos = models.IntegerField(null=True, default=None) + avatar_url = models.CharField(max_length=250, null=True) @classmethod def get_new_slug(cls, name: str) -> str: return _get_unique_slug(name, cls.objects, "channel") + def get_full_description(self) -> str: + desc = f"https://www.youtube.com/channel/{self.id}" + if self.description: + desc = f"{self.description}\n\n{desc}" + return desc + def __str__(self): return self.name @@ -66,5 +73,11 @@ class Video(models.Model): title_w_date, cls.objects.filter(channel_id=channel_id), "video" ) + def get_full_description(self) -> str: + desc = f"https://youtu.be/{self.id}" + if self.description: + desc = f"{self.description}\n\n{desc}" + return desc + def __str__(self): return self.title diff --git a/ucast/queue.py b/ucast/queue.py new file mode 100644 index 0000000..d3726d2 --- /dev/null +++ b/ucast/queue.py @@ -0,0 +1,87 @@ +import redis +import rq +import rq_scheduler +from django.conf import settings +from rq import registry + +from ucast.service import util + + +def get_redis_connection() -> redis.client.Redis: + return redis.Redis.from_url(settings.REDIS_URL) + + +def get_queue() -> rq.Queue: + redis_conn = get_redis_connection() + return rq.Queue(default_timeout=settings.REDIS_QUEUE_TIMEOUT, connection=redis_conn) + + +def get_scheduler(interval=60) -> rq_scheduler.Scheduler: + redis_conn = get_redis_connection() + return rq_scheduler.Scheduler(connection=redis_conn, interval=interval) + + +def get_worker(**kwargs) -> rq.Worker: + queue = get_queue() + return rq.Worker( + queue, + connection=queue.connection, + default_result_ttl=settings.REDIS_QUEUE_RESULT_TTL, + **kwargs, + ) + + +def enqueue(f, *args, **kwargs) -> rq.job.Job: + queue = get_queue() + # return queue.enqueue(f, *args, **kwargs) + return queue.enqueue_call(f, args, kwargs) + + +def get_statistics() -> dict: + """ + Return statistics from the RQ Queue. + + Taken from the django-rq package by Selwin Ong (MIT License) + https://github.com/rq/django-rq + + :return: RQ statistics + """ + queue = get_queue() + connection = queue.connection + connection_kwargs = connection.connection_pool.connection_kwargs + + # Raw access to the first item from left of the redis list. + # This might not be accurate since new job can be added from the left + # with `at_front` parameters. + # Ideally rq should supports Queue.oldest_job + last_job_id = connection.lindex(queue.key, 0) + last_job = queue.fetch_job(last_job_id.decode("utf-8")) if last_job_id else None + if last_job: + oldest_job_timestamp = util.to_localtime(last_job.enqueued_at).strftime( + "%Y-%m-%d, %H:%M:%S" + ) + else: + oldest_job_timestamp = "-" + + # parse_class and connection_pool are not needed and not JSON serializable + connection_kwargs.pop("parser_class", None) + connection_kwargs.pop("connection_pool", None) + + finished_job_registry = registry.FinishedJobRegistry(queue.name, queue.connection) + started_job_registry = registry.StartedJobRegistry(queue.name, queue.connection) + deferred_job_registry = registry.DeferredJobRegistry(queue.name, queue.connection) + failed_job_registry = registry.FailedJobRegistry(queue.name, queue.connection) + scheduled_job_registry = registry.ScheduledJobRegistry(queue.name, queue.connection) + + return { + "name": queue.name, + "jobs": queue.count, + "oldest_job_timestamp": oldest_job_timestamp, + "connection_kwargs": connection_kwargs, + "workers": rq.Worker.count(queue=queue), + "finished_jobs": len(finished_job_registry), + "started_jobs": len(started_job_registry), + "deferred_jobs": len(deferred_job_registry), + "failed_jobs": len(failed_job_registry), + "scheduled_jobs": len(scheduled_job_registry), + } diff --git a/ucast/service/util.py b/ucast/service/util.py index 6e260ee..c01cb3f 100644 --- a/ucast/service/util.py +++ b/ucast/service/util.py @@ -1,8 +1,12 @@ +import datetime import io +import json from pathlib import Path +from typing import Any, Union import requests import slugify +from django.utils import timezone from PIL import Image AVATAR_SM_WIDTH = 100 @@ -57,3 +61,50 @@ def resize_thumbnail(original_file: Path, new_file: Path): def get_slug(text: str) -> str: return slugify.slugify(text, lowercase=False, separator="_") + + +def to_localtime(time: datetime.datetime): + """Converts naive datetime to localtime based on settings""" + + utc_time = time.replace(tzinfo=datetime.timezone.utc) + to_zone = timezone.get_default_timezone() + return utc_time.astimezone(to_zone) + + +def _get_np_attrs(o) -> dict: + """ + Return all non-protected attributes of the given object. + :param o: Object + :return: Dict of attributes + """ + return {k: v for k, v in o.__dict__.items() if not k.startswith("_")} + + +def serializer(o: Any) -> Union[str, dict, int, float, bool]: + """ + Serialize object to json-storable format + :param o: Object to serialize + :return: Serialized output data + """ + if hasattr(o, "serialize"): + return o.serialize() + if isinstance(o, (datetime.datetime, datetime.date)): + return o.isoformat() + if isinstance(o, (bool, float, int)): + return o + if hasattr(o, "__dict__"): + return _get_np_attrs(o) + return str(o) + + +def to_json(o, pretty=False) -> str: + """ + Convert object to json. + Uses the ``serialize()`` method of the target object if available. + :param o: Object to serialize + :param pretty: Prettify with indents + :return: JSON string + """ + return json.dumps( + o, default=serializer, indent=2 if pretty else None, ensure_ascii=False + ) diff --git a/ucast/service/videoutil.py b/ucast/service/videoutil.py new file mode 100644 index 0000000..601b000 --- /dev/null +++ b/ucast/service/videoutil.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from mutagen import id3 + +from ucast.models import Video + + +def tag_audio(audio_path: Path, video: Video, cover_path: Path): + title_text = f"{video.published.date().isoformat()} {video.title}" + + tag = id3.ID3(audio_path) + tag["TPE1"] = id3.TPE1(encoding=3, text=video.channel.name) # Artist + tag["TALB"] = id3.TALB(encoding=3, text=video.channel.name) # Album + tag["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title + tag["TDRC"] = id3.TDRC(encoding=3, text=video.published.date().isoformat()) # Date + tag["COMM"] = id3.COMM(encoding=3, text=video.get_full_description()) # Comment + + with open(cover_path, "rb") as albumart: + tag["APIC"] = id3.APIC( + encoding=3, mime="image/png", type=3, desc="Cover", data=albumart.read() + ) + tag.save() diff --git a/ucast/service/youtube.py b/ucast/service/youtube.py index ef68cce..7830727 100644 --- a/ucast/service/youtube.py +++ b/ucast/service/youtube.py @@ -9,11 +9,12 @@ from typing import List, Optional import feedparser import requests -from mutagen import id3 from yt_dlp import YoutubeDL from ucast.service import scrapetube, util +CHANID_REGEX = re.compile(r"""[-_a-zA-Z\d]{24}""") + class ItemNotFoundError(Exception): pass @@ -23,6 +24,10 @@ class ThumbnailNotFoundError(Exception): pass +class InvalidMetadataError(Exception): + pass + + @dataclass class VideoScraped: """ @@ -71,7 +76,8 @@ class VideoDetails: thumbnails=info["thumbnails"], is_currently_live=bool(info.get("is_live")), is_livestream=info.get("is_live") or info.get("was_live"), - is_short=info["duration"] <= 60 and info["width"] < info["height"], + is_short=info["duration"] <= 60 + and (info["width"] or 0) < (info["height"] or 0), ) def add_scraped_data(self, scraped: VideoScraped): @@ -157,24 +163,6 @@ def download_audio( return VideoDetails.from_vinfo(info) -def tag_audio(audio_path: Path, vinfo: VideoDetails, cover_path: Path): - title_text = f"{vinfo.published.date().isoformat()} {vinfo.title}" - comment = f"https://youtu.be/{vinfo.id}\n\n{vinfo.description}" - - tag = id3.ID3(audio_path) - tag["TPE1"] = id3.TPE1(encoding=3, text=vinfo.channel_name) # Artist - tag["TALB"] = id3.TALB(encoding=3, text=vinfo.channel_name) # Album - tag["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title - tag["TDRC"] = id3.TDRC(encoding=3, text=vinfo.published.date().isoformat()) # Date - tag["COMM"] = id3.COMM(encoding=3, text=comment) # Comment - - with open(cover_path, "rb") as albumart: - tag["APIC"] = id3.APIC( - encoding=3, mime="image/png", type=3, desc="Cover", data=albumart.read() - ) - tag.save() - - def channel_url_from_id(channel_id: str) -> str: return "https://www.youtube.com/channel/" + channel_id @@ -207,8 +195,7 @@ def channel_url_from_str(channel_str: str) -> str: # Channel ID return "https://www.youtube.com/channel/" + match[2] - chanid_regex = re.compile(r"""[-_a-zA-Z\d]{24}""") - if chanid_regex.match(channel_str): + if CHANID_REGEX.match(channel_str): return "https://www.youtube.com/channel/" + channel_str raise ValueError("invalid channel string") @@ -226,9 +213,20 @@ def get_channel_metadata(channel_url: str) -> ChannelMetadata: channel_id = metadata["externalId"] name = metadata["title"] - description = metadata["description"] + description = metadata["description"].strip() avatar = metadata["avatar"]["thumbnails"][0]["url"] + if not CHANID_REGEX.match(channel_id): + raise InvalidMetadataError(f"got invalid channel id {repr(channel_id)}") + + if not name: + raise InvalidMetadataError(f"no channel name found for channel {channel_id}") + + if not avatar.startswith("https://"): + raise InvalidMetadataError( + f"got invalid avatar url for channel {channel_id}: {avatar}" + ) + return ChannelMetadata(channel_id, name, description, avatar) diff --git a/ucast/tasks/download.py b/ucast/tasks/download.py index 0dedb97..5e4df48 100644 --- a/ucast/tasks/download.py +++ b/ucast/tasks/download.py @@ -1,12 +1,10 @@ import os -import django_rq from django.utils import timezone +from ucast import queue from ucast.models import Channel, Video -from ucast.service import cover, storage, util, youtube - -store = storage.Storage() +from ucast.service import cover, storage, util, videoutil, youtube def _get_or_create_channel(channel_id: str) -> Channel: @@ -17,6 +15,7 @@ def _get_or_create_channel(channel_id: str) -> Channel: youtube.channel_url_from_id(channel_id) ) channel_slug = Channel.get_new_slug(channel_data.name) + store = storage.Storage() channel_folder = store.get_channel_folder(channel_slug) util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar) @@ -61,7 +60,7 @@ def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel): ) video.save() - django_rq.enqueue(download_video, video) + queue.enqueue(download_video, video) def download_video(video: Video): @@ -71,6 +70,7 @@ def download_video(video: Video): :param video: Video object """ + store = storage.Storage() channel_folder = store.get_channel_folder(video.channel.slug) audio_file = channel_folder.get_audio(video.slug) @@ -84,13 +84,13 @@ def download_video(video: Video): cover.create_cover_file( tn_path, channel_folder.file_avatar, - details.title, + video.title, video.channel.name, cover.COVER_STYLE_BLUR, cover_file, ) - youtube.tag_audio(audio_file, details, cover_file) + videoutil.tag_audio(audio_file, video, cover_file) video.downloaded = timezone.now() video.download_size = os.path.getsize(audio_file) @@ -115,13 +115,18 @@ def import_channel(channel_id: str, limit: int = None): _load_scraped_video(vid, channel) +def update_channel(channel: Channel): + """Update a single channel from its RSS feed""" + videos = youtube.get_channel_videos_from_feed(channel.id) + + for vid in videos: + _load_scraped_video(vid, channel) + + def update_channels(): """ Update all channels from their RSS feeds and download new videos. This task is scheduled a regular intervals. """ for channel in Channel.objects.filter(active=True): - videos = youtube.get_channel_videos_from_feed(channel.id) - - for vid in videos: - _load_scraped_video(vid, channel) + queue.enqueue(update_channel, channel) diff --git a/ucast/tasks/library.py b/ucast/tasks/library.py index a7f7bb5..f7681b8 100644 --- a/ucast/tasks/library.py +++ b/ucast/tasks/library.py @@ -1,7 +1,10 @@ import os -from ucast.models import Video -from ucast.service import cover, storage +from django.utils import timezone + +from ucast import queue +from ucast.models import Channel, Video +from ucast.service import cover, storage, util, youtube def recreate_cover(video: Video): @@ -12,12 +15,10 @@ def recreate_cover(video: Video): cover_file = cf.get_cover(video.slug) if not os.path.isfile(cf.file_avatar): - print(f"could not find avatar for channel {video.channel_id}") - return + raise FileNotFoundError(f"could not find avatar for channel {video.channel_id}") if not os.path.isfile(thumbnail_file): - print(f"could not find thumbnail for video {video.id}") - return + raise FileNotFoundError(f"could not find thumbnail for video {video.id}") cover.create_cover_file( thumbnail_file, @@ -30,5 +31,58 @@ def recreate_cover(video: Video): def recreate_covers(): + for video in Video.objects.filter(downloaded__isnull=False): + queue.enqueue(recreate_cover, video) + + +def update_file_storage(): + store = storage.Storage() + for video in Video.objects.all(): - recreate_cover(video) + cf = store.get_channel_folder(video.channel.slug) + + audio_file = cf.get_audio(video.slug) + cover_file = cf.get_cover(video.slug) + tn_file = cf.get_thumbnail(video.slug) + tn_file_sm = cf.get_thumbnail(video.slug, True) + + if not os.path.isfile(audio_file) or not os.path.isfile(tn_file): + video.downloaded = None + video.download_size = None + video.save() + return + + if not os.path.isfile(tn_file_sm): + util.resize_thumbnail(tn_file, tn_file_sm) + + if not os.path.isfile(cover_file): + recreate_cover(video) + + if video.downloaded is None: + video.downloaded = timezone.now() + + video.download_size = os.path.getsize(audio_file) + video.save() + + +def update_channel_info(channel: Channel): + channel_data = youtube.get_channel_metadata(youtube.channel_url_from_id(channel.id)) + + if channel_data.avatar_url != channel.avatar_url: + store = storage.Storage() + channel_folder = store.get_channel_folder(channel.slug) + + util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar) + util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm) + + channel.avatar_url = channel_data.avatar_url + + channel.name = channel_data.name + channel.description = channel_data.description + + channel.save() + + +def update_channel_infos(): + for channel in Channel.objects.filter(active=True): + queue.enqueue(update_channel_info, channel) diff --git a/ucast/tasks/schedule.py b/ucast/tasks/schedule.py index e1fe798..35d41d5 100644 --- a/ucast/tasks/schedule.py +++ b/ucast/tasks/schedule.py @@ -1,17 +1,17 @@ import logging from datetime import datetime -import django_rq from django.conf import settings +from ucast import queue from ucast.tasks import download -scheduler = django_rq.get_scheduler() log = logging.getLogger(__name__) def clear_scheduled_jobs(): """Delete all scheduled jobs to prevent duplicates""" + scheduler = queue.get_scheduler() for job in scheduler.get_jobs(): log.debug("Deleting scheduled job %s", job) job.delete() @@ -19,6 +19,7 @@ def clear_scheduled_jobs(): def register_scheduled_jobs(): """Register all scheduled jobs""" + scheduler = queue.get_scheduler() scheduler.schedule( datetime.utcnow(), download.update_channels, diff --git a/ucast/tests/__init__.py b/ucast/tests/__init__.py index c066208..62fd2f6 100644 --- a/ucast/tests/__init__.py +++ b/ucast/tests/__init__.py @@ -1,3 +1,80 @@ -from importlib.resources import files +import json +import uuid +from dataclasses import dataclass +from datetime import datetime +from importlib import resources +from typing import Dict, List -DIR_TESTFILES = files("ucast.tests._testfiles") +from ucast.service import youtube + +DIR_TESTFILES = resources.path("ucast.tests", "_testfiles") + + +def get_video_details(video_id: str): + with open(DIR_TESTFILES / "fixture" / "videodetails.json") as f: + videodetails = json.load(f) + + vd_raw = videodetails[video_id] + vd_raw["published"] = datetime.fromisoformat(vd_raw["published"]) + + return youtube.VideoDetails(**vd_raw) + + +def get_channel_metadata(channel_url: str): + with open(DIR_TESTFILES / "fixture" / "channelmeta.json") as f: + channelmeta = json.load(f) + + return youtube.ChannelMetadata(**channelmeta[channel_url]) + + +_global_mock_calls: Dict[str, List["_GlobalMockCall"]] = {} + + +@dataclass +class _GlobalMockCall: + args: list + kwargs: dict + + +class GlobalMock: + def __init__(self): + self.uuid = str(uuid.uuid4()) + + @property + def calls(self) -> List[_GlobalMockCall]: + global _global_mock_calls + + if self.uuid not in _global_mock_calls: + _global_mock_calls[self.uuid] = [] + + return _global_mock_calls[self.uuid] + + @property + def n_calls(self) -> int: + return len(self.calls) + + def __call__(self, *args, **kwargs): + call = _GlobalMockCall(args, kwargs) + self.calls.append(call) + + def assert_called(self): + if not self.calls: + raise AssertionError("Mock has never been called") + + def assert_any_call(self, *args, **kwargs): + self.assert_called() + + for call in self.calls: + if call.args == args and call.kwargs == kwargs: + return + + raise AssertionError( + f"Call with args: {args}, kwargs: {kwargs} not found.\ +Registered calls: {self.calls}" + ) + + def assert_called_with(self, *args, **kwargs): + self.assert_called() + + call = self.calls[-1] + assert call.args == args and call.kwargs == kwargs diff --git a/ucast/tests/_testfiles/avatar/a4.jpg b/ucast/tests/_testfiles/avatar/a4.jpg new file mode 100644 index 0000000..6985cc4 Binary files /dev/null and b/ucast/tests/_testfiles/avatar/a4.jpg differ diff --git a/ucast/tests/_testfiles/fixture/channelmeta.json b/ucast/tests/_testfiles/fixture/channelmeta.json new file mode 100644 index 0000000..8147023 --- /dev/null +++ b/ucast/tests/_testfiles/fixture/channelmeta.json @@ -0,0 +1,20 @@ +{ + "https://www.youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q": { + "id": "UCGiJh0NZ52wRhYKYnuZI08Q", + "name": "ThetaDev", + "description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.", + "avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" + }, + "https://www.youtube.com/channel/UC2TXq_t06Hjdr2g_KdKpHQg": { + "id": "UC2TXq_t06Hjdr2g_KdKpHQg", + "name": "media.ccc.de", + "description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)", + "avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj" + }, + "https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ": { + "id": "UCmLTTbctUZobNQrr8RtX8uQ", + "name": "Creative Commons", + "description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.", + "avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj" + } +} diff --git a/ucast/tests/_testfiles/fixture/videodetails.json b/ucast/tests/_testfiles/fixture/videodetails.json new file mode 100644 index 0000000..6f360c6 --- /dev/null +++ b/ucast/tests/_testfiles/fixture/videodetails.json @@ -0,0 +1,2524 @@ +{ + "Cda4zS-1j-k": { + "id": "Cda4zS-1j-k", + "title": "ThetaDevlog#1 - MySensors Smart Home!", + "description": "Smart Home devices have been around for some time and can really make your life easier. But most of them are quite pricey and not always worth the money.\\n\\nHow about a sytem that costs only 5€ per device and has all the benefits of the expensive solutions? The open source project MySensors claims to do that. In this series I'll try this and find out whether it works!\\n\\n______________________________________________\\nMy website: https://thdev.org\\nTwitter: https://twitter.com/Theta_Dev", + "channel_id": "UCGiJh0NZ52wRhYKYnuZI08Q", + "channel_name": "ThetaDev", + "duration": 303, + "published": "2018-02-17T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/mqdefault.jpg", + "preference": -11, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hqdefault.jpg", + "preference": -7, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBo35ByiZVi2KJJeCV-8-yoYvBdAQ", + "height": 94, + "width": 168, + "preference": -7, + "id": "31", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCDdK44y6kY1C0qC3mROP4gfj8_NA", + "height": 110, + "width": 196, + "preference": -7, + "id": "32", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCcisQxuJJIaxR24OmmokBS6fptdw", + "height": 138, + "width": 246, + "preference": -7, + "id": "33", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCueV3Mpte283qx1bxflp6QLw7njQ", + "height": 188, + "width": 336, + "preference": -7, + "id": "34", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/Cda4zS-1j-k/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/Cda4zS-1j-k/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "mmEDPbbSnaY": { + "id": "mmEDPbbSnaY", + "title": "ThetaDevlog#2 - MySensors singleLED", + "description": "The PCBs and components for the MySensors smart home devices arrived!\nIn this video I'll show you how to build the singleLED controller to switch/dim your 12V led lights. Detailed building instructions can be found on OpenHardware or GitHub.\n\n__PROJECT_LINKS___________________________\nOpenHardware: https://www.openhardware.io/view/563\nGitHub: https://github.com/Theta-Dev/MySensors-singleLED\n\nProgramming adapter: https://thdev.org/?Projects___misc___micro_JST\nBoard definitions: http://files.thdev.org/arduino/atmega.zip\n\n__COMPONENT_SUPPLIERS__________________\nElectronic components: https://www.aliexpress.com/\nPCBs: http://www.allpcb.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev\n______________________________________________\nMusic by Bartlebeats: https://bartlebeats.bandcamp.com", + "channel_id": "UCGiJh0NZ52wRhYKYnuZI08Q", + "channel_name": "ThetaDev", + "duration": 463, + "published": "2018-03-26T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/mqdefault.jpg", + "preference": -11, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hqdefault.jpg", + "preference": -7, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBtbbM_j3hyFktGGEEGbkjvBEgogg", + "height": 94, + "width": 168, + "preference": -7, + "id": "31", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDzu2Ay5fneJ54vZvxST6swEnZHkQ", + "height": 110, + "width": 196, + "preference": -7, + "id": "32", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAb_W3QsfMhtHbZ1J8WXuwN7zgm8w", + "height": 138, + "width": 246, + "preference": -7, + "id": "33", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCmNj6gp3dek9IalRYwkxl2UKaPaw", + "height": 188, + "width": 336, + "preference": -7, + "id": "34", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/mmEDPbbSnaY/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/mmEDPbbSnaY/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "_I5IFObm_-k": { + "id": "_I5IFObm_-k", + "title": "Easter special: 3D printed Bunny", + "description": "Happy Easter 2018!\nThis is just a special video where I print a little bunny as an Easter gift for friends or relatives. I hope you like the model, too.\n\nSadly my camera doesn't support timelapses, so I had to record the whole 4h printing process in real time, resulting in 30GB of footage. But I think it was worth it ;-)\n\n__PROJECT_LINKS___________________________\nBunny: https://www.thingiverse.com/thing:287884\n\n__COMPONENT_SUPPLIERS__________________\n3D printer: https://www.prusa3d.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", + "channel_id": "UCGiJh0NZ52wRhYKYnuZI08Q", + "channel_name": "ThetaDev", + "duration": 511, + "published": "2018-03-31T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/mqdefault.jpg", + "preference": -11, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hqdefault.jpg", + "preference": -7, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBckXFnIUTPIo3du7Aib07GR5XvXQ", + "height": 94, + "width": 168, + "preference": -7, + "id": "31", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCm-FRLtAgtEqVPhn5s903AxI14tQ", + "height": 110, + "width": 196, + "preference": -7, + "id": "32", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDxmRpK0m6NzFC3pKsnhHoWOk5TxQ", + "height": 138, + "width": 246, + "preference": -7, + "id": "33", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDAQAhfpPIcSl2Phbbb07pjX_MgxQ", + "height": 188, + "width": 336, + "preference": -7, + "id": "34", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/_I5IFObm_-k/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/_I5IFObm_-k/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "ZPxEr4YdWt8": { + "id": "ZPxEr4YdWt8", + "title": "ThetaDev @ Embedded World 2019", + "description": "This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-)\n\nSorry for the late upload, I just didn't have time to edit my footage.\n\nEmbedded World: https://www.embedded-world.de/\n\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", + "channel_id": "UCGiJh0NZ52wRhYKYnuZI08Q", + "channel_name": "ThetaDev", + "duration": 267, + "published": "2019-06-02T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/mqdefault.jpg", + "preference": -11, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hqdefault.jpg", + "preference": -7, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBr0uwI7zmo7wj2p1WYdubjepm30A", + "height": 94, + "width": 168, + "preference": -7, + "id": "31", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA5V9yVTJ3astmnCNEYzOL2NAEjEQ", + "height": 110, + "width": 196, + "preference": -7, + "id": "32", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBobN_nBJtfzaL9v2Uzzj6AznoBWw", + "height": 138, + "width": 246, + "preference": -7, + "id": "33", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAB74khszPrPc5pDsTBs7SaqrGcJQ", + "height": 188, + "width": 336, + "preference": -7, + "id": "34", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/ZPxEr4YdWt8/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/ZPxEr4YdWt8/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "2xfXsqyd8YA": { + "id": "2xfXsqyd8YA", + "title": "cy: Log4Shell - Bug oder Feature", + "description": "https://media.ccc.de/v/gpn20-60-log4shell-bug-oder-feature\n\n\n\nUm den Jahreswechsel ging ein Aufschrei durch die IT-Abteilungen der Welt, der es bis in die Mainstream-Medien geschafft hat. Noch Wochen später zeigen sich Folgeprobleme in weit verbreiteter Software.\n \nIn Log4j, einer weit verbreiteten Java-Bibliothek wurde eine massive Sicherheitslücke gefunden, die die Ausführung von Schadcode auf einem entfernten System erlaubt.\nIn diesem Vortrag soll rekapitulierend erklärt werden, warum und wann es zu dem Problem kam und welche Auswirkungen bisher erkennbar sind. Ausserdem werden die technischen Details der Schwachstelle erklärt und in einer Live-Demo gezeigt, wie die Schwachstelle ausgenutzt werden kann.\n\n\n\ncy\n\nhttps://cfp.gulas.ch/gpn20/talk/77BCXN/\n\n#gpn20 #Security", + "channel_id": "UC2TXq_t06Hjdr2g_KdKpHQg", + "channel_name": "media.ccc.de", + "duration": 3547, + "published": "2022-05-21T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/mqdefault.jpg", + "preference": -11, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hqdefault.jpg", + "preference": -7, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBSekjbNaSjlWz9X6pxuXdyf2sXzQ", + "height": 94, + "width": 168, + "preference": -7, + "id": "31", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDXmdLqsOkLDy14dfCi8LGyZjeVRw", + "height": 110, + "width": 196, + "preference": -7, + "id": "32", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDNKNNj2ak8Tnro1uUXfC5dYRkADQ", + "height": 138, + "width": 246, + "preference": -7, + "id": "33", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCVsc9fva5LJoDIj5MD3veu7W3Xjg", + "height": 188, + "width": 336, + "preference": -7, + "id": "34", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/2xfXsqyd8YA/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/2xfXsqyd8YA/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "I0RRENheeTo": { + "id": "I0RRENheeTo", + "title": "No copyright intro free fire intro | no text | free copy right | free templates | free download", + "description": "Like Video▬▬▬▬▬❤👍❤\n▬▬👇SUBSCRIBE OUR CHANNEL FOR LATEST UPDATES👆▬▬\nThis Channel: https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ?sub_confirmation=1\nOther Channel: https://www.youtube.com/channel/UCKtfYFXi5A4KLIUdjgvfmHg?sub_confirmation=1\n▬▬▬▬▬▬▬▬/Subscription Free\\▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬🎁...Share Video To Friends...🎁▬▬▬▬▬▬▬\n▬▬▬▬🤔...Comment Any Questions....🤔▬▬▬▬▬▬\nHello friends, \n Shahzaib Hassan and you are watching Creative Commons YouTube channel. On this channel, you will find all the videos absolutely free copyright which you can download and use in any project.\n It is copyright free so you won't have any problem using end screen for YouTube. if you use it or download and reupload it to your channel. By doing this you can use it for YouTube its use is absolutely free.\n ►I hope you'll like the video.◄\n ►Thanks For Watching◄ \nIf you really like this video then please don't forget to...\n\n\n▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬▬▬▬▬▬Tags👇▬▬▬▬▬▬▬▬▬▬\n#Creativecommons #commoncreative #free #freecopyright #nocopyright #nowatermark #freetouse #intro #notext #fireefire #channelintro", + "channel_id": "UCmLTTbctUZobNQrr8RtX8uQ", + "channel_name": "Creative Commons", + "duration": 8, + "published": "2021-10-10T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/mqdefault.jpg", + "preference": -11, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hqdefault.jpg", + "preference": -7, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA6-nUmpOc633CJvtsLQURQykuEuQ", + "height": 94, + "width": 168, + "preference": -7, + "id": "31", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDDD2-nMxugQDOGNrUnf8gL0xgXfg", + "height": 110, + "width": 196, + "preference": -7, + "id": "32", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBYUwqOOluRhv0J67HBC_JP9ftTkQ", + "height": 138, + "width": 246, + "preference": -7, + "id": "33", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAEGPfsNqAqP-BUikDkRGkkxlx94g", + "height": 188, + "width": 336, + "preference": -7, + "id": "34", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/I0RRENheeTo/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/I0RRENheeTo/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "uFqgQ35wyYY": { + "id": "uFqgQ35wyYY", + "title": "Systemabsturz Teaser zur DiVOC bb3", + "description": "https://media.ccc.de/v/divoc_bb3-teaser\n\n\n\n#divocbb3", + "channel_id": "UC2TXq_t06Hjdr2g_KdKpHQg", + "channel_name": "media.ccc.de", + "duration": 221, + "published": "2022-04-12T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/mqdefault.jpg", + "preference": -11, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hqdefault.jpg", + "preference": -7, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAIDX4AmOoLQj833ycCgtlK2Dk7bw", + "height": 94, + "width": 168, + "preference": -7, + "id": "31", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLApG6dF4XsZ0z6fwrFBjtD0cdzWgg", + "height": 110, + "width": 196, + "preference": -7, + "id": "32", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC0XaToFw8t1WgIEg84iw_nzYvrrg", + "height": 138, + "width": 246, + "preference": -7, + "id": "33", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAC67SefniVt5l_G434hYLuXTSQ7w", + "height": 188, + "width": 336, + "preference": -7, + "id": "34", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/uFqgQ35wyYY/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/uFqgQ35wyYY/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "eRsGyueVLvQ": { + "id": "eRsGyueVLvQ", + "title": "Sintel - Open Movie by Blender Foundation", + "description": "Help us making Free/Open Movies: https://cloud.blender.org/join\n\n\"Sintel\" is an independently produced short film, initiated by the Blender Foundation as a means to further improve and validate the free/open source 3D creation suite Blender. With initial funding provided by 1000s of donations via the internet community, it has again proven to be a viable development model for both open 3D technology as for independent animation film.\nThis 15 minute film has been realized in the studio of the Amsterdam Blender Institute, by an international team of artists and developers. In addition to that, several crucial technical and creative targets have been realized online, by developers and artists and teams all over the world.\n\nwww.sintel.org", + "channel_id": "UCSMOQeBJ2RAnuFungnQOxLg", + "channel_name": "Blender", + "duration": 888, + "published": "2010-09-30T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/default.jpg", + "height": 90, + "width": 120, + "preference": -13, + "id": "24", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/default.webp", + "preference": -12, + "id": "25" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11, + "id": "26", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/mqdefault.webp", + "preference": -10, + "id": "27" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDCPKgbu0CMK28UFd1Sxndgz3RYvg", + "height": 94, + "width": 168, + "preference": -7, + "id": "30", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLC4m2OfMNLRDR-3LB9UU8YRsmTeCw", + "height": 110, + "width": 196, + "preference": -7, + "id": "31", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCHy8hDbiekvhpps4Ka64uo9DWJcA", + "height": 138, + "width": 246, + "preference": -7, + "id": "32", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB2qqvrDCJDt3VrOWbESqL3fuyjww", + "height": 188, + "width": 336, + "preference": -7, + "id": "33", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7, + "id": "34", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/hqdefault.webp", + "preference": -6, + "id": "35" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/sddefault.webp", + "preference": -4, + "id": "37" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/eRsGyueVLvQ/maxresdefault.jpg", + "preference": -1, + "id": "40" + }, + { + "url": "https://i.ytimg.com/vi_webp/eRsGyueVLvQ/maxresdefault.webp", + "preference": 0, + "id": "41" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "DWjFW7Yq1fA": { + "id": "DWjFW7Yq1fA", + "title": "Persuasion (Instrumental) – RYYZN (No Copyright Music)", + "description": "Download free and safe music for content creators (Free Music, No Copyright Music, Royalty-free Music, and Creative Commons) · http://bit.ly/Join-the-family\n\nPlease subscribe! ❤️\nWe upload a new song EVERY day http://bit.ly/32TUZyy\n\n———\n\n⭐ Free Download: https://bit.ly/-persuasion\n\n———\n\n⚠️ You’re free to use this song in any of your YouTube videos, but you MUST include the following in your video description (Copy & Paste):\n\n––––––––––––––––––––––––––––––\nPersuasion (Instrumental) by RYYZN https://soundcloud.com/ryyzn\nCreative Commons — Attribution 3.0 Unported — CC BY 3.0\nFree Download / Stream: https://bit.ly/-persuasion\nMusic promoted by Audio Library https://youtu.be/DWjFW7Yq1fA\n––––––––––––––––––––––––––––––\n\n🎵 Track Info:\n\nTitle: Persuasion (Instrumental) by RYYZN\nGenre and Mood: Hip Hop & Rap + Inspirational\n\n———\n\n🎧 Available on: \n\nSpotify: https://spoti.fi/33Yd3d2\niTunes: https://apple.co/3m3CGiM\nDeezer: https://deezer.com/us/track/1118163262\nYouTube: https://youtube.com/watch?v=xU4eG3wi6kI\nSoundCloud: https://soundcloud.com/ryyzn/persuasion\nYouTube Music: https://music.youtube.com/watch?v=xU4eG3wi6kI\n\n———\n\n😊 Contact the Artist:\n\nRYYZN:\nweareryyzn@gmail.com\nhttps://www.weareryyzn.com\nhttps://soundcloud.com/ryyzn\nhttps://open.spotify.com/artist/54YpMpAIJC7FV2toZvVo5f\nhttps://music.apple.com/us/artist/ryyzn/1370606286\nhttps://youtube.com/channel/UCOFoLUyWX1yHmiYGQuCMhuA\nhttps://instagram.com/weareryyzn\nhttps://twitter.com/weareryyzn\nhttps://facebook.com/RYYZNN\n\n———\n\n✅ About using the music:\n\n- You MUST include the full credits in your video description.\n- You can NOT claim the music as your own.\n- You can NOT sell the music anywhere.\n- You can NOT use the music as background music for your own musical work without the artist's consent.\n- You can NOT use the music without giving any credits in the video description.\n- You can NOT remove or add parts from/to the credits.\n- You can NOT use third-party software to download the video/track, always use our download links\n- You MUST contact the artist if you wish to use the music on any kind of project outside of YouTube.\n\n⚠️ Important:\n\n- If you don't follow these policies, you can get a copyright claim/strike.\n- If you need more information about using music, please get in touch with the artist.\n- This channel is not an official YouTube channel.\n\n———\n\n💚 Listen to our Spotify playlists\n\nEDM: http://spoti.fi/30R0jTw\nLo-Fi: http://spoti.fi/38OptGM\nChill: http://spoti.fi/3rX5gWN\nHappy: https://spoti.fi/2Qjk6JD\n\n———\n\n🔴 Official Releases: http://bit.ly/al-plus\n\nWe have created a new channel where we do an agreement with the artist and our record label to make sure all the music is free and 100% safe. \n\n———\n\n🔀 More free music:\n\nPop Music: http://bit.ly/pop-music-playlist\nHip Hop & Rap Music: http://bit.ly/hip-hop-rap-playlist\nDance & Electronic Music: http://bit.ly/dance-electronic-playlist\n\nMore playlists: http://bit.ly/audiolibrary-playlists\n\n———\n\n❤️ Get in touch with Audio Library:\n\nE-mail: hello@audiolibrary.com.co\nWebsite: https://audiolibrary.com.co\nInstagram: http://bit.ly/aI-instagram\nFacebook: http://bit.ly/al-facebook\nSoundCloud: http://bit.ly/al-soundcloud\nTwitter: http://bit.ly/aI-twitter\n\n———\n\n🔥 Send your demo: http://audiolibrary.com.co/demo\n\n———\n\nPlease report to (report@audiolibrary.com.co) any issues with this video/song/image, from broken links, copyright claim/strikes to someone using the music without giving credit or selling the music.\n\n———\n\nRead our disclaimer before using the music.\nhttp://bit.ly/2KA71pn\n\n———\n\n#VlogMusic #NoCopyrightMusic #AudioLibrary", + "channel_id": "UCht8qITGkBvXKsR1Byln-wA", + "channel_name": "Audio Library — Music for content creators", + "duration": 100, + "published": "2020-12-29T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/default.jpg", + "height": 90, + "width": 120, + "preference": -13, + "id": "24", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/default.webp", + "preference": -12, + "id": "25" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11, + "id": "26", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/mqdefault.webp", + "preference": -10, + "id": "27" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLASWvhaBau7aoe2SBMRHymTkFKoXg", + "height": 94, + "width": 168, + "preference": -7, + "id": "30", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCVZxlF6DzA5EqSPlE8JE0LuEm7FA", + "height": 110, + "width": 196, + "preference": -7, + "id": "31", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCwToXcSOEbLv5Ypoog4FTJSEeTKA", + "height": 138, + "width": 246, + "preference": -7, + "id": "32", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC2JkE-QccK1h_aJ8-AY0bWZaOoYg", + "height": 188, + "width": 336, + "preference": -7, + "id": "33", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7, + "id": "34", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/hqdefault.webp", + "preference": -6, + "id": "35" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5, + "id": "36", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/sddefault.webp", + "preference": -4, + "id": "37" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/DWjFW7Yq1fA/maxresdefault.jpg", + "height": 1080, + "width": 1920, + "preference": -1, + "id": "40", + "resolution": "1920x1080" + }, + { + "url": "https://i.ytimg.com/vi_webp/DWjFW7Yq1fA/maxresdefault.webp", + "preference": 0, + "id": "41" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": false + }, + "lcQZ6YwQHiw": { + "id": "lcQZ6YwQHiw", + "title": "Small pink flowers | #shorts | Free Stock Video | creative commons short videos | creative #short", + "description": "Like Video▬▬▬▬▬❤👍❤\n▬▬👇SUBSCRIBE OUR CHANNEL FOR LATEST UPDATES👆▬▬\nThis Channel: https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ?sub_confirmation=1\nOther Channel: https://www.youtube.com/channel/UCKtfYFXi5A4KLIUdjgvfmHg?sub_confirmation=1\n▬▬▬▬▬▬▬▬/Subscription Free\\▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬🎁...Share Video To Friends...🎁▬▬▬▬▬▬▬\n▬▬▬▬🤔...Comment Any Questions....🤔▬▬▬▬▬▬\nHello friends, \n Shahzaib Hassan and you are watching Creative Commons YouTube channel. On this channel, you will find all the videos absolutely free copyright which you can download and use in any project.\n It is copyright free so you won't have any problem using end screen for YouTube. if you use it or download and reupload it to your channel. By doing this you can use it for YouTube its use is absolutely free.\n ►I hope you'll like the video.◄\n ►Thanks For Watching◄ \nIf you really like this video then please don't forget to...\n❤❤❤❤❤❤❤❤❤❤❤❤❤❤\n▬▬▬▬▬▬▬▬▬⭐Other Playlists⭐▬▬▬▬▬▬▬▬▬\n►Outro or End Screen :➜ https://www.youtube.com/watch?v=nmY5xtWNs5E&list=PLHVlnSiwtzdsgRnQkjGRESdLfHX-THA_w\n►Free Count Down :➜ https://www.youtube.com/watch?v=U9mQM1Cd0iI&list=PLHVlnSiwtzduzjs5cyGqjUeZkwN6nf_t7\n►Free Lower Thirds :➜ https://www.youtube.com/watch?v=JJgUCNWQ3BM&list=PLHVlnSiwtzdsfozn_qXSHnvBPDHvqyAD2\n►Free Green Screen :➜ https://www.youtube.com/watch?v=JJgUCNWQ3BM&list=PLHVlnSiwtzds64zSAgPIuItuAD50ishi3\n►Free Channel Intro :➜ https://www.youtube.com/watch?v=twje3dtjlUo&list=PLHVlnSiwtzduBEp_f8yvqE3yZMJ7CrR6c\n►Free Cut Video Footage:➜ https://www.youtube.com/watch?v=2zv7Wl0lWhI&list=PLHVlnSiwtzdv8hW3PbvkDLvMKPVGwsHbO\n►Free Waterfall Videos :➜ https://www.youtube.com/watch?v=nRsnME_4zjc&list=PLHVlnSiwtzduKqb7TDYORlx29pDWXJrhE\n\n▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬▬▬▬▬▬Tags👇▬▬▬▬▬▬▬▬▬▬\n#Creativecommons #commoncreative #free #freecopyright #nocopyright #nowatermark #freetouse #short #Shorts video #FreeStockVideo #freetouse #freedownload #shorts#creativecommonsshortvideos #creativecommons #shortvideos #shorts", + "channel_id": "UCmLTTbctUZobNQrr8RtX8uQ", + "channel_name": "Creative Commons", + "duration": 21, + "published": "2021-08-21T00:00:00+00:00", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hq2.jpg", + "height": 360, + "width": 480, + "preference": -23, + "id": "14", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/default.jpg", + "height": 90, + "width": 120, + "preference": -13, + "id": "24", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/default.webp", + "preference": -12, + "id": "25" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11, + "id": "26", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/mqdefault.webp", + "preference": -10, + "id": "27" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAQ60jDMSyKbLn_U8Ivdz5FN2b2Zw", + "height": 94, + "width": 168, + "preference": -7, + "id": "30", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCib9e26bIx66au4IXYm3QzvLWRQw", + "height": 110, + "width": 196, + "preference": -7, + "id": "31", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBqXjbOoHD14mcL3OysN4lKmM573w", + "height": 138, + "width": 246, + "preference": -7, + "id": "32", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDG-0HC7Y3oOH8Jbh3_xQ4gIfJ_Bw", + "height": 188, + "width": 336, + "preference": -7, + "id": "33", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7, + "id": "34", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/hqdefault.webp", + "preference": -6, + "id": "35" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/sddefault.jpg", + "preference": -5, + "id": "36" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/sddefault.webp", + "preference": -4, + "id": "37" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/lcQZ6YwQHiw/maxresdefault.jpg", + "preference": -1, + "id": "40" + }, + { + "url": "https://i.ytimg.com/vi_webp/lcQZ6YwQHiw/maxresdefault.webp", + "preference": 0, + "id": "41" + } + ], + "is_currently_live": false, + "is_livestream": false, + "is_short": true + } +} diff --git a/ucast/tests/_testfiles/fixture/videos.json b/ucast/tests/_testfiles/fixture/videos.json new file mode 100644 index 0000000..f2a9b65 --- /dev/null +++ b/ucast/tests/_testfiles/fixture/videos.json @@ -0,0 +1,141 @@ +[ + { + "model": "ucast.channel", + "pk": "UCGiJh0NZ52wRhYKYnuZI08Q", + "fields": { + "name": "ThetaDev", + "slug": "ThetaDev", + "description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.", + "active": true, + "skip_livestreams": true, + "skip_shorts": true, + "keep_videos": null, + "avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" + } + }, + { + "model": "ucast.channel", + "pk": "UC2TXq_t06Hjdr2g_KdKpHQg", + "fields": { + "name": "media.ccc.de", + "slug": "media_ccc_de", + "description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)", + "active": true, + "skip_livestreams": true, + "skip_shorts": true, + "keep_videos": null, + "avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj" + } + }, + { + "model": "ucast.channel", + "pk": "UCmLTTbctUZobNQrr8RtX8uQ", + "fields": { + "name": "Creative Commons", + "slug": "Creative_Commons", + "description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.", + "active": true, + "skip_livestreams": true, + "skip_shorts": true, + "keep_videos": null, + "avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj" + } + }, + + { + "model": "ucast.video", + "pk": "ZPxEr4YdWt8", + "fields": { + "title": "ThetaDev @ Embedded World 2019", + "slug": "20190602_ThetaDev_Embedded_World_2019", + "channel": "UCGiJh0NZ52wRhYKYnuZI08Q", + "published": "2019-06-02T00:00:00Z", + "downloaded": "2022-05-15T22:16:03.096Z", + "description": "This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-)\n\nSorry for the late upload, I just didn't have time to edit my footage.\n\nEmbedded World: https://www.embedded-world.de/\n\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", + "duration": 267, + "is_livestream": false, + "is_short": false, + "download_size": 4558477 + } + }, + { + "model": "ucast.video", + "pk": "_I5IFObm_-k", + "fields": { + "title": "Easter special: 3D printed Bunny", + "slug": "20180331_Easter_special_3D_printed_Bunny", + "channel": "UCGiJh0NZ52wRhYKYnuZI08Q", + "published": "2018-03-31T00:00:00Z", + "downloaded": "2022-05-15T22:16:12.514Z", + "description": "Happy Easter 2018!\nThis is just a special video where I print a little bunny as an Easter gift for friends or relatives. I hope you like the model, too.\n\nSadly my camera doesn't support timelapses, so I had to record the whole 4h printing process in real time, resulting in 30GB of footage. But I think it was worth it ;-)\n\n__PROJECT_LINKS___________________________\nBunny: https://www.thingiverse.com/thing:287884\n\n__COMPONENT_SUPPLIERS__________________\n3D printer: https://www.prusa3d.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", + "duration": 511, + "is_livestream": false, + "is_short": false, + "download_size": 8444518 + } + }, + { + "model": "ucast.video", + "pk": "mmEDPbbSnaY", + "fields": { + "title": "ThetaDevlog#2 - MySensors singleLED", + "slug": "20180326_ThetaDevlog_2_MySensors_singleLED", + "channel": "UCGiJh0NZ52wRhYKYnuZI08Q", + "published": "2018-03-26T00:00:00Z", + "downloaded": "2022-05-15T22:16:20.280Z", + "description": "The PCBs and components for the MySensors smart home devices arrived!\nIn this video I'll show you how to build the singleLED controller to switch/dim your 12V led lights. Detailed building instructions can be found on OpenHardware or GitHub.\n\n__PROJECT_LINKS___________________________\nOpenHardware: https://www.openhardware.io/view/563\nGitHub: https://github.com/Theta-Dev/MySensors-singleLED\n\nProgramming adapter: https://thdev.org/?Projects___misc___micro_JST\nBoard definitions: http://files.thdev.org/arduino/atmega.zip\n\n__COMPONENT_SUPPLIERS__________________\nElectronic components: https://www.aliexpress.com/\nPCBs: http://www.allpcb.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev\n______________________________________________\nMusic by Bartlebeats: https://bartlebeats.bandcamp.com", + "duration": 463, + "is_livestream": false, + "is_short": false, + "download_size": 7648860 + } + }, + { + "model": "ucast.video", + "pk": "Cda4zS-1j-k", + "fields": { + "title": "ThetaDevlog#1 - MySensors Smart Home!", + "slug": "20180217_ThetaDevlog_1_MySensors_Smart_Home", + "channel": "UCGiJh0NZ52wRhYKYnuZI08Q", + "published": "2018-02-17T00:00:00Z", + "downloaded": "2022-05-15T22:16:25.237Z", + "description": "Smart Home devices have been around for some time and can really make your life easier. But most of them are quite pricey and not always worth the money.\n\nHow about a sytem that costs only 5€ per device and has all the benefits of the expensive solutions? The open source project MySensors claims to do that. In this series I'll try this and find out whether it works!\n\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev", + "duration": 303, + "is_livestream": false, + "is_short": false, + "download_size": 5091124 + } + }, + { + "model": "ucast.video", + "pk": "2xfXsqyd8YA", + "fields": { + "title": "cy: Log4Shell - Bug oder Feature", + "slug": "20220521_cy_Log4Shell_Bug_oder_Feature", + "channel": "UC2TXq_t06Hjdr2g_KdKpHQg", + "published": "2022-05-21T00:00:00Z", + "downloaded": null, + "description": "https://media.ccc.de/v/gpn20-60-log4shell-bug-oder-feature\n\n\n\nUm den Jahreswechsel ging ein Aufschrei durch die IT-Abteilungen der Welt, der es bis in die Mainstream-Medien geschafft hat. Noch Wochen später zeigen sich Folgeprobleme in weit verbreiteter Software.\n \nIn Log4j, einer weit verbreiteten Java-Bibliothek wurde eine massive Sicherheitslücke gefunden, die die Ausführung von Schadcode auf einem entfernten System erlaubt.\nIn diesem Vortrag soll rekapitulierend erklärt werden, warum und wann es zu dem Problem kam und welche Auswirkungen bisher erkennbar sind. Ausserdem werden die technischen Details der Schwachstelle erklärt und in einer Live-Demo gezeigt, wie die Schwachstelle ausgenutzt werden kann.\n\n\n\ncy\n\nhttps://cfp.gulas.ch/gpn20/talk/77BCXN/\n\n#gpn20 #Security", + "duration": 3547, + "is_livestream": false, + "is_short": false, + "download_size": null + } + }, + { + "model": "ucast.video", + "pk": "I0RRENheeTo", + "fields": { + "title": "No copyright intro free fire intro | no text | free copy right | free templates | free download", + "slug": "20211010_No_copyright_intro_free_fire_intro_no_text_free_copy_right_free_templates_free_download", + "channel": "UCmLTTbctUZobNQrr8RtX8uQ", + "published": "2021-10-10T00:00:00Z", + "downloaded": null, + "description": "Like Video▬▬▬▬▬❤\uD83D\uDC4D❤\n▬▬\uD83D\uDC47SUBSCRIBE OUR CHANNEL FOR LATEST UPDATES\uD83D\uDC46▬▬\nThis Channel: https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ?sub_confirmation=1\nOther Channel: https://www.youtube.com/channel/UCKtfYFXi5A4KLIUdjgvfmHg?sub_confirmation=1\n▬▬▬▬▬▬▬▬/Subscription Free\\▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬\uD83C\uDF81...Share Video To Friends...\uD83C\uDF81▬▬▬▬▬▬▬\n▬▬▬▬\uD83E\uDD14...Comment Any Questions....\uD83E\uDD14▬▬▬▬▬▬\nHello friends, \n Shahzaib Hassan and you are watching Creative Commons YouTube channel. On this channel, you will find all the videos absolutely free copyright which you can download and use in any project.\n It is copyright free so you won't have any problem using end screen for YouTube. if you use it or download and reupload it to your channel. By doing this you can use it for YouTube its use is absolutely free.\n ►I hope you'll like the video.◄\n ►Thanks For Watching◄ \nIf you really like this video then please don't forget to...\n\n\n▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬▬▬▬▬▬Tags\uD83D\uDC47▬▬▬▬▬▬▬▬▬▬\n#Creativecommons #commoncreative #free #freecopyright #nocopyright #nowatermark #freetouse #intro #notext #fireefire #channelintro", + "duration": 8, + "is_livestream": false, + "is_short": false, + "download_size": null + } + } +] diff --git a/ucast/tests/_testfiles/thumbnail/Cda4zS-1j-k.webp b/ucast/tests/_testfiles/thumbnail/Cda4zS-1j-k.webp new file mode 100644 index 0000000..4994c80 Binary files /dev/null and b/ucast/tests/_testfiles/thumbnail/Cda4zS-1j-k.webp differ diff --git a/ucast/tests/_testfiles/thumbnail/ZPxEr4YdWt8.webp b/ucast/tests/_testfiles/thumbnail/ZPxEr4YdWt8.webp new file mode 100644 index 0000000..7c1bfb3 Binary files /dev/null and b/ucast/tests/_testfiles/thumbnail/ZPxEr4YdWt8.webp differ diff --git a/ucast/tests/_testfiles/thumbnail/_I5IFObm_-k.webp b/ucast/tests/_testfiles/thumbnail/_I5IFObm_-k.webp new file mode 100644 index 0000000..6da0984 Binary files /dev/null and b/ucast/tests/_testfiles/thumbnail/_I5IFObm_-k.webp differ diff --git a/ucast/tests/_testfiles/thumbnail/mmEDPbbSnaY.webp b/ucast/tests/_testfiles/thumbnail/mmEDPbbSnaY.webp new file mode 100644 index 0000000..f16cda4 Binary files /dev/null and b/ucast/tests/_testfiles/thumbnail/mmEDPbbSnaY.webp differ diff --git a/ucast/tests/conftest.py b/ucast/tests/conftest.py new file mode 100644 index 0000000..b61ae27 --- /dev/null +++ b/ucast/tests/conftest.py @@ -0,0 +1,106 @@ +import shutil +import tempfile +from pathlib import Path +from unittest import mock + +import pytest +import rq +from django.conf import settings +from django.core.management import call_command +from fakeredis import FakeRedis + +from ucast import queue, tests +from ucast.models import Video +from ucast.service import cover, storage, util, videoutil, youtube + + +@pytest.fixture(scope="session") +def django_db_setup(django_db_setup, django_db_blocker): + with django_db_blocker.unblock(): + fixture_path = tests.DIR_TESTFILES / "fixture" / "videos.json" + call_command("loaddata", fixture_path) + + +@pytest.fixture +def download_dir() -> Path: + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + settings.DOWNLOAD_ROOT = tmpdir + + # Copy channel avatars + store = storage.Storage() + + for slug, avatar in ( + ("ThetaDev", "a1"), + ("media_ccc_de", "a3"), + ("Creative_Commons", "a4"), + ): + cf = store.get_channel_folder(slug) + shutil.copyfile( + tests.DIR_TESTFILES / "avatar" / f"{avatar}.jpg", cf.file_avatar + ) + util.resize_avatar(cf.file_avatar, cf.file_avatar_sm) + + yield tmpdir + + +@pytest.fixture +@pytest.mark.django_db +def download_dir_content(download_dir) -> Path: + store = storage.Storage() + + for video in Video.objects.filter(downloaded__isnull=False): + cf = store.get_channel_folder(video.channel.slug) + file_audio = cf.get_audio(video.slug) + file_tn = cf.get_thumbnail(video.slug) + file_cover = cf.get_cover(video.slug) + + shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", file_audio) + shutil.copyfile(tests.DIR_TESTFILES / "thumbnail" / f"{video.id}.webp", file_tn) + util.resize_thumbnail(file_tn, cf.get_thumbnail(video.slug, True)) + cover.create_cover_file( + file_tn, + cf.file_avatar, + video.title, + video.channel.name, + cover.COVER_STYLE_BLUR, + file_cover, + ) + videoutil.tag_audio(file_audio, video, file_cover) + + yield download_dir + + +@pytest.fixture +def rq_queue(mocker) -> rq.Queue: + test_queue = rq.Queue(is_async=False, connection=FakeRedis()) + mocker.patch.object(queue, "get_queue") + queue.get_queue.return_value = test_queue + return test_queue + + +@pytest.fixture +def mock_download_audio(mocker) -> mock.Mock: + def mockfn_download_audio( + video_id: str, download_path: Path, sponsorblock=False + ) -> youtube.VideoDetails: + shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", download_path) + return tests.get_video_details(video_id) + + download_mock: mock.Mock = mocker.patch.object(youtube, "download_audio") + download_mock.side_effect = mockfn_download_audio + return download_mock + + +@pytest.fixture +def mock_get_video_details(mocker) -> mock.Mock: + video_details_mock: mock.Mock = mocker.patch.object(youtube, "get_video_details") + video_details_mock.side_effect = tests.get_video_details + return video_details_mock + + +@pytest.fixture +def mock_get_channel_metadata(mocker) -> mock.Mock: + channel_meta_mock: mock.Mock = mocker.patch.object(youtube, "get_channel_metadata") + channel_meta_mock.side_effect = tests.get_channel_metadata + return channel_meta_mock diff --git a/ucast/tests/service/test_videoutil.py b/ucast/tests/service/test_videoutil.py new file mode 100644 index 0000000..3b709b9 --- /dev/null +++ b/ucast/tests/service/test_videoutil.py @@ -0,0 +1,52 @@ +import io +import shutil +import tempfile +from pathlib import Path + +import pytest +from mutagen import id3 +from PIL import Image, ImageChops + +from ucast import tests +from ucast.models import Video +from ucast.service import videoutil + + +@pytest.mark.django_db +def test_tag_audio(): + video = Video.objects.get(id="ZPxEr4YdWt8") + + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + audio_file = tmpdir / "audio.mp3" + cover_file = tests.DIR_TESTFILES / "cover" / "c1_blur.png" + shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", audio_file) + + videoutil.tag_audio(audio_file, video, cover_file) + + tag = id3.ID3(audio_file) + assert tag["TPE1"].text[0] == "ThetaDev" + assert tag["TALB"].text[0] == "ThetaDev" + assert tag["TIT2"].text[0] == "2019-06-02 ThetaDev @ Embedded World 2019" + assert tag["TDRC"].text[0].text == "2019-06-02" + assert ( + tag["COMM::XXX"].text[0] + == """This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-) + +Sorry for the late upload, I just didn't have time to edit my footage. + +Embedded World: https://www.embedded-world.de/ + +My website: https://thdev.org +Twitter: https://twitter.com/Theta_Dev + +https://youtu.be/ZPxEr4YdWt8""" + ) + + tag_cover = tag["APIC:Cover"] + assert tag_cover.mime == "image/png" + + tag_cover_img = Image.open(io.BytesIO(tag_cover.data)) + expected_cover_img = Image.open(cover_file) + diff = ImageChops.difference(tag_cover_img, expected_cover_img) + assert diff.getbbox() is None diff --git a/ucast/tests/service/test_youtube.py b/ucast/tests/service/test_youtube.py index 0b45228..c93a476 100644 --- a/ucast/tests/service/test_youtube.py +++ b/ucast/tests/service/test_youtube.py @@ -1,13 +1,10 @@ import datetime -import io import re -import shutil import subprocess import tempfile from pathlib import Path import pytest -from mutagen import id3 from PIL import Image, ImageChops from ucast import tests @@ -101,43 +98,6 @@ def test_download_audio(): assert match[1] == "00:01:40" -def test_tag_audio(video_details): - tmpdir_o = tempfile.TemporaryDirectory() - tmpdir = Path(tmpdir_o.name) - audio_file = tmpdir / "audio.mp3" - cover_file = tests.DIR_TESTFILES / "cover" / "c1_blur.png" - shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", audio_file) - - youtube.tag_audio(audio_file, video_details, cover_file) - - tag = id3.ID3(audio_file) - assert tag["TPE1"].text[0] == "ThetaDev" - assert tag["TALB"].text[0] == "ThetaDev" - assert tag["TIT2"].text[0] == "2019-06-02 ThetaDev @ Embedded World 2019" - assert tag["TDRC"].text[0].text == "2019-06-02" - assert ( - tag["COMM::XXX"].text[0] - == """https://youtu.be/ZPxEr4YdWt8 - -This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-) - -Sorry for the late upload, I just didn't have time to edit my footage. - -Embedded World: https://www.embedded-world.de/ - -My website: https://thdev.org -Twitter: https://twitter.com/Theta_Dev""" - ) - - tag_cover = tag["APIC:Cover"] - assert tag_cover.mime == "image/png" - - tag_cover_img = Image.open(io.BytesIO(tag_cover.data)) - expected_cover_img = Image.open(cover_file) - diff = ImageChops.difference(tag_cover_img, expected_cover_img) - assert diff.getbbox() is None - - @pytest.mark.parametrize( "channel_str,channel_url", [ diff --git a/ucast/tests/tasks/__init__.py b/ucast/tests/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ucast/tests/tasks/test_download.py b/ucast/tests/tasks/test_download.py new file mode 100644 index 0000000..c5818c2 --- /dev/null +++ b/ucast/tests/tasks/test_download.py @@ -0,0 +1,81 @@ +import os + +import pytest + +from ucast import queue, tests +from ucast.models import Channel, Video +from ucast.service import storage +from ucast.tasks import download + +CHANNEL_ID_THETADEV = "UCGiJh0NZ52wRhYKYnuZI08Q" +VIDEO_ID_INTRO = "I0RRENheeTo" +VIDEO_SLUG_INTRO = "20211010_No_copyright_intro_free_fire_intro_no_text_free_copy_right_free_templates_free_download" + + +@pytest.mark.django_db +def test_download_video(download_dir, rq_queue): + video = Video.objects.get(id=VIDEO_ID_INTRO) + job = queue.enqueue(download.download_video, video) + + store = storage.Storage() + cf = store.get_channel_folder(video.channel.slug) + + assert job.is_finished + + assert os.path.isfile(cf.get_audio(VIDEO_SLUG_INTRO)) + assert os.path.isfile(cf.get_cover(VIDEO_SLUG_INTRO)) + assert os.path.isfile(cf.get_thumbnail(VIDEO_SLUG_INTRO)) + assert os.path.isfile(cf.get_thumbnail(VIDEO_SLUG_INTRO, True)) + + +@pytest.mark.django_db +def test_import_channel( + download_dir, rq_queue, mock_get_video_details, mock_download_audio +): + # Remove 2 videos from the database so they can be imported + Video.objects.get(id="ZPxEr4YdWt8").delete() + Video.objects.get(id="_I5IFObm_-k").delete() + + job = rq_queue.enqueue(download.import_channel, CHANNEL_ID_THETADEV) + assert job.is_finished + + mock_download_audio.assert_any_call( + "_I5IFObm_-k", + download_dir / "ThetaDev" / "20180331_Easter_special_3D_printed_Bunny.mp3", + ) + mock_download_audio.assert_any_call( + "ZPxEr4YdWt8", + download_dir / "ThetaDev" / "20190602_ThetaDev_Embedded_World_2019.mp3", + ) + + +@pytest.mark.django_db +def test_update_channel( + download_dir, rq_queue, mock_get_video_details, mock_download_audio +): + # Remove 2 videos from the database so they can be imported + Video.objects.get(id="ZPxEr4YdWt8").delete() + Video.objects.get(id="_I5IFObm_-k").delete() + + channel = Channel.objects.get(id=CHANNEL_ID_THETADEV) + job = rq_queue.enqueue(download.update_channel, channel) + assert job.is_finished + + mock_download_audio.assert_any_call( + "_I5IFObm_-k", + download_dir / "ThetaDev" / "20180331_Easter_special_3D_printed_Bunny.mp3", + ) + mock_download_audio.assert_any_call( + "ZPxEr4YdWt8", + download_dir / "ThetaDev" / "20190602_ThetaDev_Embedded_World_2019.mp3", + ) + + +@pytest.mark.django_db +def test_update_channels(rq_queue, mocker): + update_channel_mock = tests.GlobalMock() + mocker.patch.object(download, "update_channel", update_channel_mock) + job = rq_queue.enqueue(download.update_channels) + assert job.is_finished + + assert update_channel_mock.n_calls == 3 diff --git a/ucast/tests/tasks/test_library.py b/ucast/tests/tasks/test_library.py new file mode 100644 index 0000000..9722316 --- /dev/null +++ b/ucast/tests/tasks/test_library.py @@ -0,0 +1,72 @@ +from unittest import mock + +import pytest + +from ucast import tests +from ucast.models import Channel, Video +from ucast.service import cover, storage +from ucast.tasks import library + +CHANNEL_ID_THETADEV = "UCGiJh0NZ52wRhYKYnuZI08Q" + + +@pytest.mark.django_db +def test_recreate_cover(download_dir_content, rq_queue, mocker): + create_cover_mock: mock.Mock = mocker.patch.object(cover, "create_cover_file") + + video = Video.objects.get(id="ZPxEr4YdWt8") + + store = storage.Storage() + cf = store.get_channel_folder(video.channel.slug) + + job = rq_queue.enqueue(library.recreate_cover, video) + assert job.is_finished + + create_cover_mock.assert_called_once_with( + cf.get_thumbnail(video.slug), + cf.file_avatar, + video.title, + video.channel.name, + cover.COVER_STYLE_BLUR, + cf.get_cover(video.slug), + ) + + +@pytest.mark.django_db +def test_recreate_covers(rq_queue, mocker): + recreate_cover_mock = tests.GlobalMock() + mocker.patch.object(library, "recreate_cover", recreate_cover_mock) + + job = rq_queue.enqueue(library.recreate_covers) + assert job.is_finished + + assert recreate_cover_mock.n_calls == 4 + + +@pytest.mark.django_db +def test_update_channel_info(rq_queue, mock_get_channel_metadata): + channel = Channel.objects.get(id=CHANNEL_ID_THETADEV) + + channel.description = "Old description" + channel.save() + + job = rq_queue.enqueue(library.update_channel_info, channel) + assert job.is_finished + + channel.refresh_from_db() + assert ( + channel.description + == "I'm ThetaDev. I love creating cool projects \ +using electronics, 3D printers and other awesome tech-based stuff." + ) + + +@pytest.mark.django_db +def test_update_channel_infos(rq_queue, mocker): + update_channel_mock = tests.GlobalMock() + mocker.patch.object(library, "update_channel_info", update_channel_mock) + + job = rq_queue.enqueue(library.update_channel_infos) + assert job.is_finished + + assert update_channel_mock.n_calls == 3 diff --git a/ucast_project/settings.py b/ucast_project/settings.py index c7790e7..639db56 100644 --- a/ucast_project/settings.py +++ b/ucast_project/settings.py @@ -52,7 +52,6 @@ def _load_dotenv() -> Path: if dotenv_path: dotenv.load_dotenv(dotenv_path) - print(f"Loaded config from envfile at {dotenv_path}") default_workdir = Path(dotenv_path).resolve().parent os.chdir(default_workdir) @@ -92,7 +91,6 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "bulma", - "django_rq", ] MIDDLEWARE = [ @@ -203,24 +201,8 @@ STATICFILES_DIRS = [resources.path("ucast", "static")] DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -REDIS_HOST = get_env("REDIS_HOST", "localhost") -REDIS_PORT = get_env("REDIS_PORT", 6379) -REDIS_PASSWORD = get_env("REDIS_PASSWORD", "") -REDIS_DB = get_env("REDIS_DB", 0) +REDIS_URL = get_env("REDIS_URL", "redis://localhost:6379") REDIS_QUEUE_TIMEOUT = get_env("REDIS_QUEUE_TIMEOUT", 600) REDIS_QUEUE_RESULT_TTL = 600 -RQ_QUEUES = { - "default": { - "HOST": REDIS_HOST, - "PORT": REDIS_PORT, - "DB": REDIS_DB, - "PASSWORD": REDIS_PASSWORD, - "DEFAULT_TIMEOUT": REDIS_QUEUE_TIMEOUT, - "DEFAULT_RESULT_TTL": REDIS_QUEUE_RESULT_TTL, - } -} - -RQ_SHOW_ADMIN_LINK = True - YT_UPDATE_INTERVAL = get_env("YT_UPDATE_INTERVAL", 900) diff --git a/ucast_project/urls.py b/ucast_project/urls.py index 8a3101a..52ea2a4 100644 --- a/ucast_project/urls.py +++ b/ucast_project/urls.py @@ -17,7 +17,6 @@ from django.contrib import admin from django.urls import include, path urlpatterns = [ - path("admin/django-rq/", include("django_rq.urls")), path("admin/", admin.site.urls), path("", include("ucast.urls")), ]