initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
3
config/logs/Lidarr-MusicVideoAutomator-2026_03_04_09_51_AM.txt
Executable file
3
config/logs/Lidarr-MusicVideoAutomator-2026_03_04_09_51_AM.txt
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
2026-03-04 09:51:38 :: Lidarr-MusicVideoAutomator (v2.2) :: Starting...
|
||||||
|
2026-03-04 09:51:38 :: Lidarr-MusicVideoAutomator (v2.2) :: apk not found, skipping dependency installation...
|
||||||
|
2026-03-04 09:51:38 :: Lidarr-MusicVideoAutomator (v2.2) :: ERROR :: No config files found, exiting...
|
||||||
308
new_dl.py
Executable file
308
new_dl.py
Executable file
@@ -0,0 +1,308 @@
|
|||||||
|
from ast import Dict
|
||||||
|
import asyncio
|
||||||
|
from enum import Enum
|
||||||
|
import json
|
||||||
|
from queue import Queue
|
||||||
|
import time
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import HTTPError, Request, urlopen
|
||||||
|
from musicbrainzngs import musicbrainz
|
||||||
|
import pprint
|
||||||
|
import aiohttp
|
||||||
|
from textual.reactive import reactive
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Footer, Header, Label, ListView, ListItem
|
||||||
|
from textual.widget import Widget
|
||||||
|
from yt_dlp import YoutubeDL
|
||||||
|
from thefuzz import fuzz
|
||||||
|
|
||||||
|
|
||||||
|
def falsy(value: Any) -> bool:
|
||||||
|
return not value or value is False
|
||||||
|
|
||||||
|
|
||||||
|
JobType = Enum(
|
||||||
|
"JobType", ["fetch_artists", "fetch_artist_videos", "find_video_sources"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type Job = {"type": JobType.value, "payload": Any}
|
||||||
|
|
||||||
|
type MBRelation = {}
|
||||||
|
|
||||||
|
type MBRecording = {
|
||||||
|
"length": Optional[int],
|
||||||
|
"title": Optional[str],
|
||||||
|
"id": str,
|
||||||
|
"disambiguation": Optional[str],
|
||||||
|
"first-release-date": Optional[str],
|
||||||
|
"video": Optional[bool],
|
||||||
|
"relations": Optional[list[MBRelation]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JobWidget(ListItem):
|
||||||
|
def __init__(self, job: Job):
|
||||||
|
super().__init__()
|
||||||
|
self.job = job
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label(self.job["name"])
|
||||||
|
|
||||||
|
|
||||||
|
class JobWidget(ListItem):
|
||||||
|
def __init__(self, job: dict):
|
||||||
|
super().__init__()
|
||||||
|
self.job = job
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
yield Label(self.job["name"])
|
||||||
|
|
||||||
|
|
||||||
|
class JobList(ListView):
|
||||||
|
jobs = reactive([], recompose=True) # trigger recompose on change
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
# This is the only place we map jobs -> ListItems
|
||||||
|
for job in self.jobs:
|
||||||
|
yield JobWidget(job)
|
||||||
|
|
||||||
|
|
||||||
|
# class MVDownloaderTUI(App):
|
||||||
|
# def compose(self):
|
||||||
|
# yield Header()
|
||||||
|
# job_list = JobList(id="job-queue")
|
||||||
|
# yield job_list
|
||||||
|
# yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
# class MVDownloaderTUI(App):
|
||||||
|
# BINDINGS = [
|
||||||
|
# ("a", "add_item", "Add a new job")
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# jobs = reactive([])
|
||||||
|
|
||||||
|
# def compose(self) -> ComposeResult:
|
||||||
|
# yield Header()
|
||||||
|
# yield Label("Job Queue")
|
||||||
|
# job_list = JobList(id="job-queue")
|
||||||
|
# yield job_list
|
||||||
|
# yield Footer()
|
||||||
|
|
||||||
|
# def watch_jobs(self, jobs):
|
||||||
|
# list_view = self.query_one("#job-queue", ListView)
|
||||||
|
# list_view.clear()
|
||||||
|
# for job in jobs:
|
||||||
|
# list_view.append(JobWidget(job))
|
||||||
|
|
||||||
|
# def _action_add_item(self) -> None:
|
||||||
|
# job_list = self.query_one("#job-queue", JobList)
|
||||||
|
# id = len(job_list.jobs)
|
||||||
|
# job_list.jobs = [*job_list.jobs, {"id": id, "name": "Test Job"}]
|
||||||
|
|
||||||
|
type UrlSource = {"url": str, "type": str}
|
||||||
|
|
||||||
|
type Video = {
|
||||||
|
"id": str,
|
||||||
|
"title": Optional[str],
|
||||||
|
"artist": Optional[str],
|
||||||
|
"year": Optional[str],
|
||||||
|
"source_urls": Optional[list[UrlSource]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MVDownloader:
|
||||||
|
def __init__(self):
|
||||||
|
self.temp_path = "/mnt/user/data/downloads/temp"
|
||||||
|
self.download_path = "/mnt/user/data/downloads/downloads"
|
||||||
|
self.lidarr_api_key = "36fb27b01480452b8e5d01a0a0ce9979"
|
||||||
|
self.lidarr_url = "http://10.0.0.101:8686"
|
||||||
|
self.musicbrainzapi = musicbrainz
|
||||||
|
self.artists = {}
|
||||||
|
self.videos: dict[str, Video] = {}
|
||||||
|
self.fetch_log = {}
|
||||||
|
self.queue = Queue[Job]()
|
||||||
|
self.semaphore = asyncio.Semaphore(10)
|
||||||
|
self.session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
# async def http_get_json(self, url: str, headers: Optional[Dict[str, str]] = None) -> Any:
|
||||||
|
# req = Request(url, headers=headers or {})
|
||||||
|
# try:
|
||||||
|
# with urlopen(req, timeout=None) as resp:
|
||||||
|
# data = resp.read()
|
||||||
|
# return json.loads(data.decode("utf-8"))
|
||||||
|
# except (URLError, HTTPError, json.JSONDecodeError) as e:
|
||||||
|
# self.log(f"ERROR :: HTTP/JSON error for {url}: {e}")
|
||||||
|
# return None
|
||||||
|
|
||||||
|
async def http_get_json(
|
||||||
|
self, url: str, headers: Optional[Dict[str, str]] = None
|
||||||
|
) -> Any:
|
||||||
|
async with self.semaphore:
|
||||||
|
try:
|
||||||
|
async with self.session.get(url, headers=headers) as resp:
|
||||||
|
return await resp.json()
|
||||||
|
except (URLError, HTTPError, json.JSONDecodeError) as e:
|
||||||
|
self.log(f"ERROR :: HTTP/JSON error for {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch_artists(self):
|
||||||
|
base = self.lidarr_url.rstrip("/")
|
||||||
|
url = f"{base}/api/v1/artist?apikey={self.lidarr_api_key}"
|
||||||
|
artists = await self.http_get_json(url)
|
||||||
|
for artist in artists:
|
||||||
|
id = artist["foreignArtistId"]
|
||||||
|
if falsy(self.fetch_log.get("lidarr_artists")):
|
||||||
|
self.fetch_log["lidarr_artists"] = {}
|
||||||
|
if falsy(self.fetch_log["lidarr_artists"].get(id)):
|
||||||
|
self.queue.put(
|
||||||
|
{
|
||||||
|
"type": JobType.fetch_artist_videos,
|
||||||
|
"payload": {"id": id, "name": artist["artistName"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if falsy(self.artists.get(id)):
|
||||||
|
self.artists[id] = {}
|
||||||
|
self.artists[id]["name"] = artist["artistName"]
|
||||||
|
# print(results)
|
||||||
|
# await self.fetch_artist_videos(artists[0]["id"])
|
||||||
|
|
||||||
|
def filter_videos_factory(self, payload: dict[str, str]):
|
||||||
|
def filter_videos(info_dict, incomplete: bool):
|
||||||
|
extra_points = {
|
||||||
|
"music video": 50,
|
||||||
|
"lyric video": -25,
|
||||||
|
}
|
||||||
|
# print(payload)
|
||||||
|
# if info_dict['playlist']:
|
||||||
|
# return "It's a playlist"
|
||||||
|
if info_dict.get("title"):
|
||||||
|
yt_title = info_dict["title"]
|
||||||
|
if payload["video"]["title"].lower() not in yt_title.lower():
|
||||||
|
return "Artist or title mismatch"
|
||||||
|
score = 0
|
||||||
|
# print(info_dict)
|
||||||
|
test = f"{payload['video']['title']} - {payload['video']['artist-credit-phrase']}"
|
||||||
|
# print(f"YT Title: {yt_title}")
|
||||||
|
# print(f"Test: {test}")
|
||||||
|
score += fuzz.token_sort_ratio(yt_title, test)
|
||||||
|
for keyword, points in extra_points.items():
|
||||||
|
if keyword.lower() in yt_title.lower():
|
||||||
|
score += points
|
||||||
|
if (
|
||||||
|
info_dict.get("uploader").lower()
|
||||||
|
== payload["video"]["artist-credit-phrase"].lower()
|
||||||
|
):
|
||||||
|
score += 10
|
||||||
|
# print(f"Score: {score}")
|
||||||
|
return
|
||||||
|
return None
|
||||||
|
|
||||||
|
return filter_videos
|
||||||
|
|
||||||
|
async def find_video_sources(self, payload: dict[str, str]):
|
||||||
|
filter_videos = self.filter_videos_factory(payload)
|
||||||
|
ydl_opts = {
|
||||||
|
"t": "sleep",
|
||||||
|
"noplaylist": True,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"extract_flat": True,
|
||||||
|
"extract_audio": False,
|
||||||
|
"audio_format": "best",
|
||||||
|
"default_search": "",
|
||||||
|
"skip_download": True,
|
||||||
|
"match_filter": filter_videos,
|
||||||
|
}
|
||||||
|
# print(payload)
|
||||||
|
artist = payload["video"]["artist-credit-phrase"]
|
||||||
|
title = payload["video"]["title"]
|
||||||
|
if artist == "20SIX Hundred":
|
||||||
|
return
|
||||||
|
|
||||||
|
search_query = f"{artist} - {title}"
|
||||||
|
with YoutubeDL(ydl_opts) as ydl:
|
||||||
|
output = ydl.download(
|
||||||
|
[f"https://youtube.com/results?search_query={search_query}"]
|
||||||
|
)
|
||||||
|
# print(output)
|
||||||
|
# id = payload['id']
|
||||||
|
# video = payload['video']
|
||||||
|
# print(video)
|
||||||
|
|
||||||
|
async def fetch_mb_relations(self, id: str):
|
||||||
|
info = self.musicbrainzapi.get_recording_by_id(id, includes=["url-rels"])
|
||||||
|
|
||||||
|
async def fetch_artist_videos(self, payload: dict[str, str]):
|
||||||
|
self.musicbrainzapi.set_useragent("MVDownloader", "1.0.0")
|
||||||
|
id = payload["id"]
|
||||||
|
|
||||||
|
if self.fetch_log.get("video_list") is None:
|
||||||
|
self.fetch_log["video_list"] = {}
|
||||||
|
if falsy(self.fetch_log["video_list"].get(id)):
|
||||||
|
recordings = self.musicbrainzapi.search_recordings(
|
||||||
|
strict=True, arid=id, video=True
|
||||||
|
)
|
||||||
|
for recording in recordings["recording-list"]:
|
||||||
|
info = self.musicbrainzapi.get_recording_by_id(
|
||||||
|
recording["id"], includes=["url-rels"]
|
||||||
|
)
|
||||||
|
print("inside for recording")
|
||||||
|
if info.get("relations"):
|
||||||
|
print("inside info.get('relations')")
|
||||||
|
for relation in info["relations"]:
|
||||||
|
if relation["target-type"] == "url":
|
||||||
|
print("relation: ", relation)
|
||||||
|
# print("info: ", info)
|
||||||
|
await self.find_video_sources({"id": id, "video": recording})
|
||||||
|
self.queue.put(
|
||||||
|
{
|
||||||
|
"type": JobType.find_video_sources,
|
||||||
|
"payload": {"id": id, "video": recording},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# for video in videos:
|
||||||
|
# await self.find_video_sources({"id": id, "video": video})
|
||||||
|
# self.videos.append({"id": id, "videos": videos})
|
||||||
|
# self.videos.put({"type": JobType.find_video_sources, "payload": {"id": id, "videos": videos}})
|
||||||
|
# self.fetch_log["video_list"][id] = True
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
async def main(self):
|
||||||
|
await self.fetch_artists()
|
||||||
|
|
||||||
|
async def run_forever(self):
|
||||||
|
await self.fetch_artists()
|
||||||
|
while True:
|
||||||
|
task = self.queue.get()
|
||||||
|
print(task)
|
||||||
|
match task["type"]:
|
||||||
|
case JobType.fetch_artists:
|
||||||
|
await self.fetch_artists()
|
||||||
|
case JobType.fetch_artist_videos:
|
||||||
|
await self.fetch_artist_videos(task["payload"])
|
||||||
|
case JobType.find_video_sources:
|
||||||
|
await self.find_video_sources(task["payload"])
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"Unknown job type: {task.type}")
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with MVDownloader() as downloader:
|
||||||
|
await downloader.run_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# app = MVDownloaderTUI()
|
||||||
|
# app.run()
|
||||||
|
asyncio.run(main())
|
||||||
2200
poetry.lock
generated
Executable file
2200
poetry.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
53
pyproject.toml
Executable file
53
pyproject.toml
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
[project]
|
||||||
|
name = "tidal-downloader"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = [
|
||||||
|
{name = "David Freitag",email = "david@freitag.site"}
|
||||||
|
]
|
||||||
|
requires-python = ">=3.14,<4.0"
|
||||||
|
dependencies = [
|
||||||
|
"ffmpeg (>=1.4,<2.0)",
|
||||||
|
"tidalapi (>=0.8.11,<0.9.0)",
|
||||||
|
"yt-dlp (>=2026.3.3,<2027.0.0)",
|
||||||
|
"musicbrainzngs @ git+https://github.com/freitagdavid/python-musicbrainzngs-neo.git",
|
||||||
|
"python-redux (>=0.25.4,<0.26.0)",
|
||||||
|
"aiohttp (>=3.13.3,<4.0.0)",
|
||||||
|
"textual (>=8.0.2,<9.0.0)",
|
||||||
|
"textual-dev (>=1.8.0,<2.0.0)",
|
||||||
|
"lxml[cssselect] (>=6.0.2,<7.0.0)",
|
||||||
|
"cssselect (>=1.4.0,<2.0.0)",
|
||||||
|
"selenium (>=4.41.0,<5.0.0)",
|
||||||
|
"thefuzz (>=0.22.1,<0.23.0)",
|
||||||
|
"ruff (>=0.15.5,<0.16.0)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
# pycodestyle
|
||||||
|
"E",
|
||||||
|
# Pyflakes
|
||||||
|
"F",
|
||||||
|
# pyupgrade
|
||||||
|
"UP",
|
||||||
|
# flake8-bugbear
|
||||||
|
"B",
|
||||||
|
# flake8-simplify
|
||||||
|
"SIM",
|
||||||
|
# isort
|
||||||
|
"I",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.textual]
|
||||||
|
default_theme = "textual.themes.tui"
|
||||||
|
default_palette = "textual.palettes.tui"
|
||||||
|
default_font = "textual.fonts.tui"
|
||||||
|
default_font_size = 12
|
||||||
|
default_font_weight = "normal"
|
||||||
|
default_font_style = "normal"
|
||||||
|
default_font_color = "white"
|
||||||
|
default_background_color = "black"
|
||||||
|
default_foreground_color = "white"
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
60
vimeo_search.py
Normal file
60
vimeo_search.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import pprint
|
||||||
|
import urllib
|
||||||
|
import requests
|
||||||
|
import lxml.html
|
||||||
|
from selenium import webdriver
|
||||||
|
|
||||||
|
BASE_URL = "https://vimeo.com/search"
|
||||||
|
|
||||||
|
hdr = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
|
||||||
|
"Accept-Encoding": "none",
|
||||||
|
"Accept-Language": "en-US,en;q=0.8",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vimeo_page(page: str):
|
||||||
|
|
||||||
|
tree = lxml.html.fromstring(page)
|
||||||
|
items = []
|
||||||
|
videos = tree.cssselect("a.chakra-card > data-testid[clip-result]")
|
||||||
|
for item in videos:
|
||||||
|
print(item.text_content())
|
||||||
|
# title = item.xpath('.//div[@class="video-title"]/a/text()')[0]
|
||||||
|
# url = item.xpath('.//div[@class="video-title"]/a/@href')[0]
|
||||||
|
# items.append({
|
||||||
|
# "title": title,
|
||||||
|
# "url": url
|
||||||
|
# })
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def search_vimeo(type: str, query: str, price: str, resolution: str):
|
||||||
|
url = f"{BASE_URL}?type={type}&q={query}&price={price}&resolution={resolution}"
|
||||||
|
|
||||||
|
# req = requests.get(url, headers=hdr)
|
||||||
|
# the_page = req.text
|
||||||
|
# print(the_page)
|
||||||
|
# tree = parse_vimeo_page(the_page)
|
||||||
|
# return tree
|
||||||
|
|
||||||
|
driver = webdriver.Chrome()
|
||||||
|
driver.get(url)
|
||||||
|
the_page = driver.page_source
|
||||||
|
tree = parse_vimeo_page(the_page)
|
||||||
|
driver.quit()
|
||||||
|
return tree
|
||||||
|
# req = urllib.request.Request(url, headers=hdr)
|
||||||
|
# with urllib.request.urlopen(req) as response:
|
||||||
|
# the_page = response.read()
|
||||||
|
# tree = parse_vimeo_page(the_page)
|
||||||
|
# return tree
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pprint.pprint(
|
||||||
|
search_vimeo(type="clip", query="paramore", price="free", resolution="4k")
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user