feature: add navidome

This commit is contained in:
unfaiyted 2024-08-04 16:13:09 -05:00
parent c0cb7ecbee
commit 1d57f05e5f
26 changed files with 107904 additions and 148 deletions

View File

@ -1 +1 @@
{"access_token": "BQCOqPiTZyLWYouW3TxjCpFO3m15BcmPJXXYXhLZ-k36BaTCh7IVkoFiHh2uXXydEIMabbHuk9mZsImyw-lfZ1DS-_NNCEdR1z1Hy0xzBlM_OJ3ItOHMIW-xodY3czcUJWcQUvcLduUqpGiWEod_lbxN9JaYx35JA9zFozGfwba82UjrMtDsRqYQg0PMhqhqb-VCSBClg8ue0RGR8D3IdmketZRLq7Se46YvWAOBG_td-YI", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-read-private", "expires_at": 1722629879, "refresh_token": "AQAA4rKcyTFqaVctXWaxmE3d0LqLJu3ms91BNXrlJeYfy_1fYmCyQ3VyX6rO6K0O4iGyRVNWT8lcJB6MGRuC5rLXG85ZFB2XQtpcab6ECyKrIQNcMNU7AJj_eiZgfTwNtFk"} {"access_token": "BQAF13mnRW0H1bV2OpTwaODCsS29vIOvgMgYi0Yx6mzmzX8wakeLiD_Cdc33BBNyx_qId5EpdjyWqOLNsQ22Gc24iqZCue9IwYmekDyD4mTRqwsz9UrW8kpxLxk5m6zm1mYKTr6TOLw41qLbEezQ0qmxnBEWmJF_MVv1OAOJHO-9uYRYSU-Q43KI3OuQoSZfhXddRXewxeSQ7Dxz353bdAxQ0rNR7_h17f_XOvJtB4d25To", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-read-private", "expires_at": 1722697785, "refresh_token": "AQAA4rKcyTFqaVctXWaxmE3d0LqLJu3ms91BNXrlJeYfy_1fYmCyQ3VyX6rO6K0O4iGyRVNWT8lcJB6MGRuC5rLXG85ZFB2XQtpcab6ECyKrIQNcMNU7AJj_eiZgfTwNtFk"}

Binary file not shown.

View File

@ -1 +1 @@
{"access_token": "BQDLUIwaf9wl9YwDlaD-kcAg1imJM99GzbVxIRkdasZ-i1cc0UlxCjkO9-m_dRMPVLjHedkX2mcffSj2XTJDFnrv92DrImZZmOD4AZJMo0FQsXWFyQHSYWdKqgxJg0xIx9a0Xz6-_sFQoPidjYxCS6o34WBOv7agj5V0ARJif90AUxvE9j5O813JRZjLFioCgmicjgcCXylMEm80OMXYCwfEWM3ZUgNVmZ3uqRLlUcHIw5c", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-read-private", "expires_at": 1722622536, "refresh_token": "AQBij3uDeV9G6Z71rKFATWpcHJLhggDPYabbTEuvebdSKEqRY_5VqS98wkx3u037d7nBlaTHIQpeSqkbbOmLdIut56TZ3Yiva56R4t6PV22LSY0wUdTYfwSjP_nWr_jP1wU"} {"access_token": "BQAxlmvegGDayLdhwE18y2aZbW0saezyrJvq7xl1W_1mylT-MMgVrhqNkt1duZILVuoJ49EcmwCHNAYOx02q8B-DM-XDq4-HNL0UDU_Y1QvKyyUstSiqN8ogbcp3Xz8y9gDXbv9-5fF7t4US-DquOy58Ldq58BcTTo7pM15hQwI1c8EjJv-WNPGoXh9sikPnqk3df1DAcOxc-CGZKx3AYZEeAwYyGMhLqCD5Bo9jmuC8nBs", "token_type": "Bearer", "expires_in": 3600, "scope": "playlist-read-private", "expires_at": 1722809560, "refresh_token": "AQBij3uDeV9G6Z71rKFATWpcHJLhggDPYabbTEuvebdSKEqRY_5VqS98wkx3u037d7nBlaTHIQpeSqkbbOmLdIut56TZ3Yiva56R4t6PV22LSY0wUdTYfwSjP_nWr_jP1wU"}

View File

@ -0,0 +1,80 @@
# codebase/emby-scripts/src/actions/copy_org_songs_to_server.py
import os
import re
import shutil
from src.utils.logger import logger
import sys
import filecmp
# Set the source and destination directories
SOURCE_DIR = r"C:\Music\org_spotdl_downloads"
DEST_DIR = r"\\192.168.0.101\download\music"
def sanitize_filename(filename):
# Replace problematic characters with underscores
sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename)
# Remove trailing dots and spaces
sanitized = sanitized.rstrip('. ')
# Ensure the filename isn't empty after sanitization
return sanitized or "Unknown"
def copy_and_delete_files(source, destination, dry_run=False):
for root, dirs, files in os.walk(source):
# Create relative path
rel_path = os.path.relpath(root, source)
dest_path = os.path.join(destination, rel_path)
# Create destination directory if it doesn't exist
if not dry_run:
os.makedirs(dest_path, exist_ok=True)
for file in files:
src_file = os.path.join(root, file)
dest_file = os.path.join(dest_path, sanitize_filename(file))
if dry_run:
logger.info(f"Would copy {src_file} to {dest_file} and then delete {src_file}")
else:
try:
# Copy the file
shutil.copy2(src_file, dest_file)
logger.info(f"Copied {src_file} to {dest_file}")
# Verify the copy was successful
if filecmp.cmp(src_file, dest_file):
# Delete the original file
os.remove(src_file)
logger.info(f"Deleted original file: {src_file}")
else:
logger.error(f"Copy verification failed for {src_file}. File not deleted.")
except Exception as e:
logger.error(f"Error processing {src_file}: {e}")
def main():
# Check if --dry-run flag is provided
dry_run = "--dry-run" in sys.argv
if dry_run:
logger.info("Running in dry-run mode. No files will be copied or deleted.")
# Ensure the destination directory exists
if not os.path.exists(DEST_DIR):
logger.error(f"Destination directory does not exist: {DEST_DIR}")
return
logger.info(f"Starting to copy files from {SOURCE_DIR} to {DEST_DIR}")
copy_and_delete_files(SOURCE_DIR, DEST_DIR, dry_run)
logger.info("File copying and deletion process completed.")
# Clean up empty directories in the source
if not dry_run:
for root, dirs, files in os.walk(SOURCE_DIR, topdown=False):
for dir in dirs:
dir_path = os.path.join(root, dir)
if not os.listdir(dir_path):
os.rmdir(dir_path)
logger.info(f"Removed empty directory: {dir_path}")
if __name__ == "__main__":
main()

View File

@ -1,13 +1,11 @@
# codebase/emby-scripts/src/actions/delete_duplicate_playlists.py # codebase/emby-scripts/src/actions/delete_duplicate_playlists.py
import logging
from src.config import Config from src.config import Config
from src.clients.emby_client import EmbyClient from src.clients.emby_client import EmbyClient
from src.utils.logger import logger
# Configure logging # Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
def delete_duplicate_playlists(emby): def delete_duplicate_playlists(emby):
# Initialize EmbyClient # Initialize EmbyClient
@ -30,16 +28,17 @@ def delete_duplicate_playlists(emby):
if playlist_item_count > existing_item_count: if playlist_item_count > existing_item_count:
# Delete the existing playlist with fewer items # Delete the existing playlist with fewer items
emby.delete_playlist(existing_playlist['Id']) emby.delete_playlist(existing_playlist['Id'])
logging.info(f"Deleted duplicate playlist: {existing_playlist['Name']} (ID: {existing_playlist['Id']}) with {existing_item_count} items") logger.info(f"Deleted duplicate playlist: {existing_playlist['Name']} (ID: {existing_playlist['Id']}) with {existing_item_count} items")
playlists_by_name[playlist_name] = playlist playlists_by_name[playlist_name] = playlist
else: else:
# Delete the current playlist with fewer items # Delete the current playlist with fewer items
emby.delete_playlist(playlist_id) emby.delete_playlist(playlist_id)
logging.info(f"Deleted duplicate playlist: {playlist_name} (ID: {playlist_id}) with {playlist_item_count} items") logger.info(f"Deleted duplicate playlist: {playlist_name} (ID: {playlist_id}) with {playlist_item_count} items")
else: else:
playlists_by_name[playlist_name] = playlist playlists_by_name[playlist_name] = playlist
logging.info("Finished deleting duplicate playlists") logger.info("Finished deleting duplicate playlists")
if __name__ == "__main__": if __name__ == "__main__":
delete_duplicate_playlists() emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD)
delete_duplicate_playlists(emby)

View File

@ -24,7 +24,7 @@ logging.basicConfig(
spotdl_path = "C:\\Users\\Dane Miller\\Downloads\\spotdl-4.2.5-win32.exe" spotdl_path = "C:\\Users\\Dane Miller\\Downloads\\spotdl-4.2.5-win32.exe"
# Set the output directory where the songs will be saved # Set the output directory where the songs will be saved
output_dir = "C:\\Users\\Dane Miller\\Music\\spotdl_downloads" output_dir = "C:\\Music\\spotdl_downloads"
def remove_special_characters(string, skip_non_ascii=False): def remove_special_characters(string, skip_non_ascii=False):
if skip_non_ascii and not all(ord(char) < 128 for char in string): if skip_non_ascii and not all(ord(char) < 128 for char in string):

View File

@ -2,7 +2,6 @@ from src.clients.emby_client import EmbyClient
from src.config import Config from src.config import Config
import re import re
def parse_episode_string(episode_string): def parse_episode_string(episode_string):
match = re.match(r"S(\d+)E(\d+)", episode_string) match = re.match(r"S(\d+)E(\d+)", episode_string)
if match: if match:
@ -12,40 +11,8 @@ def parse_episode_string(episode_string):
else: else:
raise ValueError(f"Invalid episode string format: {episode_string}") raise ValueError(f"Invalid episode string format: {episode_string}")
# Create an instance of the Emby client
emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD) emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD)
# Define an array of TV show names
# tv_shows = ["Superstore", "Fresh Off the Boat", "What If...?", "Friends", "Avatar: The Last Airbender"]
#
# starting_episodes = {
# "Superstore": "S03E09", # Start from Season 2, Episode 3 of The Office ()
# "Fresh Off the Boat": "S03E08",
# "What If...?": "S01E05",
# "Friends": "S01E01",
# "Avatar: The Last Airbender": "S01E19"
# }
# tv_shows = ["The Following", "Ozark", "Better Call Saul",
# "The Leftovers", "Halo"]
#
#
# starting_episodes = {
# "The Following": "S01E09", # Start from Season 2, Episode 3 of The Office ()
# "Ozark": "S01E02",
# "Better Call Saul": "S05E03",
# "The Leftovers": "S01E01",
# "Halo": "S01E01"
# }
#
# Superstore S03E09
# "The Following": "S01E09"
# "Ozark": "S01E02",
# "Fresh Off the Boat": "S03E08",
# "Better Call Saul": "S05E03",
# "What If...?" "S01E05"
tv_shows = ["The Office (US)", "Bob's Burgers", "Parks and Recreation"] tv_shows = ["The Office (US)", "Bob's Burgers", "Parks and Recreation"]
starting_episodes = { starting_episodes = {

View File

@ -1,108 +1,148 @@
import os import os
import shutil import shutil
import logging
import tinytag import tinytag
import re import re
import sys import sys
import filecmp
# Set up logging import src.utils.logger as logger
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
# Set the input and output directories # Set the input and output directories
input_dir = r"C:\Users\Dane Miller\Music\spotdl_downloads" input_dir = r"C:\Music\spotdl_downloads"
output_dir = r"C:\Users\Dane Miller\Music\org_spotdl_downloads" output_dir = r"C:\Music\org_spotdl_downloads"
# Set the dry run option based on command-line argument # Set the dry run and delete source options based on command-line arguments
if len(sys.argv) > 1 and sys.argv[1] == "--dry-run": dry_run = "--dry-run" in sys.argv
dry_run = True keep_source = "--dont-delete-source" in sys.argv
else:
dry_run = False
# Regex pattern to extract year from date string # Regex pattern to extract year from date string
year_pattern = r"\d{4}" year_pattern = r"\d{4}"
import re
def sanitize_filename(filename):
def shorten_album_name(album_name, max_length=50):
# Remove common words and abbreviate
album_name = re.sub(r'\bOriginal\s+|Television\s+|Soundtrack\s+|\bOST\b', '', album_name, flags=re.IGNORECASE)
album_name = album_name.replace('Season', 'S').replace('Special', 'Sp')
# Remove text in square brackets
album_name = re.sub(r'\[.*?\]', '', album_name).strip()
# Truncate if still too long
if len(album_name) > max_length:
album_name = album_name[:max_length - 3] + '...'
return album_name.strip()
def sanitize_filename(filename, max_length=100):
# Replace problematic characters with underscores # Replace problematic characters with underscores
sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename) sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename)
return sanitized # Remove trailing dots and spaces
sanitized = sanitized.rstrip('. ')
# Truncate if too long
if len(sanitized) > max_length:
base, ext = os.path.splitext(sanitized)
sanitized = base[:max_length - len(ext) - 3] + '...' + ext
return sanitized or "Unknown"
# def sanitize_filename(filename):
# # Replace problematic characters with underscores
# sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename)
# # Remove trailing dots and spaces
# sanitized = sanitized.rstrip('. ')
# return sanitized or "Unknown"
def process_file(file_path, output_file_path):
if os.path.exists(output_file_path):
if filecmp.cmp(file_path, output_file_path):
logger.info(f"File already exists and is identical: {output_file_path}")
return True
else:
logger.warning(f"File exists but is different: {output_file_path}")
return False
if not dry_run:
try:
shutil.copy2(file_path, output_file_path)
logger.info(f"Copied {file_path} to {output_file_path}")
return True
except Exception as e:
logger.error(f"Error copying {file_path} to {output_file_path}: {e}")
return False
else:
logger.info(f"Dry run: Would have copied {file_path} to {output_file_path}")
return True
# Loop through all files in the input directory # Loop through all files in the input directory
for root, dirs, files in os.walk(input_dir): for root, dirs, files in os.walk(input_dir):
for file in files: for file in files:
if file.endswith(".mp3"): if file.endswith(".mp3"):
# Construct the full file path
file_path = os.path.join(root, file) file_path = os.path.join(root, file)
try: try:
# Read the metadata from the MP3 file
tag = tinytag.TinyTag.get(file_path) tag = tinytag.TinyTag.get(file_path)
artists = [ artists = [artist.strip() for artist in (tag.artist or "Unknown Artist").split("/")]
artist.strip()
for artist in (tag.artist or "Unknown Artist").split("/")
]
logging.info(f"Artists: {artists}")
album = tag.album or "Unknown Album" album = tag.album or "Unknown Album"
year_str = str(tag.year) if tag.year else "" year_str = str(tag.year) if tag.year else ""
year = re.search(year_pattern, year_str).group() if year_str else "" year = re.search(year_pattern, year_str).group() if year_str else ""
track_number = str(tag.track).zfill(2) if tag.track else "00" track_number = str(tag.track).zfill(2) if tag.track else "00"
title = tag.title or os.path.splitext(file)[0] title = tag.title or os.path.splitext(file)[0]
except Exception as e: except Exception as e:
# Handle errors reading metadata
artists = ["Unknown Artist"] artists = ["Unknown Artist"]
album = "Unknown Album" album = "Unknown Album"
year = "" year = ""
track_number = "00" track_number = "00"
title = os.path.splitext(file)[0] title = os.path.splitext(file)[0]
logging.warning(f"Error reading metadata from {file_path}: {e}") logger.warning(f"Error reading metadata from {file_path}: {e}")
# Create the output folder structure if it doesn't exist
album_name = f"{album} ({year})" if year else album album_name = f"{album} ({year})" if year else album
album_name = sanitize_filename(album_name) # Sanitize the album name album_name = shorten_album_name(album_name)
output_artist_dir = os.path.join( album_name = sanitize_filename(album_name)
output_dir, sanitize_filename(artists[0]) output_artist_dir = os.path.join(output_dir, sanitize_filename(artists[0], max_length=30))
) # Use only the first artist and sanitize the name output_album_dir = os.path.join(output_artist_dir, album_name.replace("_", " "))
output_album_dir = os.path.join(
output_artist_dir, album_name.replace("_", " ")
)
if not os.path.exists(output_album_dir):
os.makedirs(output_album_dir)
logging.info(f"Created directory: {output_album_dir}")
# Rename the file with the new format if not os.path.exists(output_album_dir) and not dry_run:
new_file_name = f"{album_name.replace('_', ' ')} - {track_number} - {sanitize_filename(title)}.mp3" # Remove artist names from file name and sanitize the title try:
os.makedirs(output_album_dir)
logger.info(f"Created directory: {output_album_dir}")
except Exception as e:
logger.error(f"Error creating directory: {output_album_dir}: {e}")
continue
new_file_name = f"{track_number} - {sanitize_filename(title, max_length=50)}.mp3"
output_file_path = os.path.join(output_album_dir, new_file_name) output_file_path = os.path.join(output_album_dir, new_file_name)
logging.info(f"Source file path: {file_path}") if process_file(file_path, output_file_path):
logging.info(f"Destination file path: {output_file_path}") if not keep_source and not dry_run:
if os.path.exists(file_path):
logging.info(f'File path exists.')
if dry_run:
logging.info(f"Dry run: Would have moved {file_path} to {output_file_path}")
else:
logging.info(f"Moving {file_path} to {output_file_path}")
# shutil.move(file_path, output_file_path)
try: try:
shutil.copy2(file_path, output_file_path) os.remove(file_path)
logger.info(f"Deleted source file: {file_path}")
except Exception as e: except Exception as e:
logging.error(f"Error moving {file_path} to {output_file_path}: {e}") logger.error(f"Error deleting source file {file_path}: {e}")
logging.info(f"Moved {file_path} to {output_file_path}")
else:
logging.warning(f"Source file does not exist: {file_path}")
# Check if a lyrics file exists and copy it to the new directory structure # Process lyrics file
lyrics_file = os.path.splitext(file)[0] + ".lrc" lyrics_file = os.path.splitext(file)[0] + ".lrc"
lyrics_file_path = os.path.join(root, lyrics_file) lyrics_file_path = os.path.join(root, lyrics_file)
if os.path.exists(lyrics_file_path): if os.path.exists(lyrics_file_path):
output_lyrics_file_path = os.path.join(output_album_dir, os.path.splitext(new_file_name)[0] + ".lrc") output_lyrics_file_path = os.path.join(output_album_dir, os.path.splitext(new_file_name)[0] + ".lrc")
if dry_run: if process_file(lyrics_file_path, output_lyrics_file_path):
logging.info(f"Dry run: Would have copied {lyrics_file_path} to {output_lyrics_file_path}") if not keep_source and not dry_run:
else: try:
shutil.copy2(lyrics_file_path, output_lyrics_file_path) os.remove(lyrics_file_path)
logging.info(f"Copied {lyrics_file_path} to {output_lyrics_file_path}") logger.info(f"Deleted source lyrics file: {lyrics_file_path}")
except Exception as e:
logger.error(f"Error deleting source lyrics file {lyrics_file_path}: {e}")
# Clean up empty directories in the source if deleting source files
if not keep_source and not dry_run:
for root, dirs, files in os.walk(input_dir, topdown=False):
for dir in dirs:
dir_path = os.path.join(root, dir)
if not os.listdir(dir_path):
try:
os.rmdir(dir_path)
logger.info(f"Removed empty directory: {dir_path}")
except Exception as e:
logger.error(f"Error removing empty directory {dir_path}: {e}")

View File

@ -1,7 +1,6 @@
import spotipy import spotipy
from spotipy.oauth2 import SpotifyOAuth from spotipy.oauth2 import SpotifyOAuth
import requests import requests
import logging
import json import json
from fuzzywuzzy import fuzz from fuzzywuzzy import fuzz
import re import re
@ -9,21 +8,10 @@ from src.config import Config
from src.utils.string import StringUtils from src.utils.string import StringUtils
from src.clients.emby_client import EmbyClient from src.clients.emby_client import EmbyClient
from src.clients.spotify_client import SpotifyClient, clean_title from src.clients.spotify_client import SpotifyClient, clean_title
from src.utils.logger import logger
import sqlite3 import sqlite3
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
# Get the user's playlists from Spotify
# spot = SpotifyClient(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET, Config.SPOTIFY_REDIRECT_URI,
# Config.SPOTIFY_SCOPE)
# emby = EmbyClient(Config.EMBY_URL, Config.EMBY_USERNAME, Config.EMBY_PASSWORD)
# Connect to the SQLite database (it will be created if it doesn't exist)
def try_match_and_add(track_name, artist_name, emby_playlist, emby, spot): def try_match_and_add(track_name, artist_name, emby_playlist, emby, spot):
emby_search_results = emby.search_for_track(track_name, artist_name) emby_search_results = emby.search_for_track(track_name, artist_name)
@ -31,15 +19,15 @@ def try_match_and_add(track_name, artist_name, emby_playlist, emby, spot):
if emby_search_results: if emby_search_results:
for result in emby_search_results: for result in emby_search_results:
emby_item_id = result["Id"] emby_item_id = result["Id"]
logging.debug(f'Matching {track_name} with {result["Name"]}') logger.debug(f'Matching {track_name} with {result["Name"]}')
if spot.match_song({"name": track_name, "artists": [{"name": artist_name}]}, result): if spot.match_song({"name": track_name, "artists": [{"name": artist_name}]}, result):
try: try:
emby.add_item_to_playlist(emby_playlist['Id'], emby_item_id) emby.add_item_to_playlist(emby_playlist['Id'], emby_item_id)
# logging.info(f"Added '{track_name}' by {artist_name} to Emby playlist") # logging.info(f"Added '{track_name}' by {artist_name} to Emby playlist")
return True return True
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.warning(f"Error adding track to Emby playlist: {track_name}") logger.warning(f"Error adding track to Emby playlist: {track_name}")
logging.warning(f"Error message: {str(e)}") logger.warning(f"Error message: {str(e)}")
return False return False
def sync_spotify_playlists(spot, emby): def sync_spotify_playlists(spot, emby):
@ -52,16 +40,29 @@ def sync_spotify_playlists(spot, emby):
playlists = spot.get_playlists() playlists = spot.get_playlists()
featured_playlists = spot.get_featured_playlists() featured_playlists = spot.get_featured_playlists()
# print(json.dumps(featured_playlists, indent=4)) # made_for_you = spot.get_made_for_you()
# list of categories to get playlists from
categories = ["Made for You", "Pop", "Country", "Summer", "Discover", "In the Car"]
all_playlists = playlists["items"] + featured_playlists["items"] all_playlists = playlists["items"] + featured_playlists["items"]
# get all the playlists from the categories
for category in categories:
logger.info(f"Getting playlists from category: {category}")
all_playlists += spot.get_category_playlists_by_name(category)["items"]
# print(json.dumps(featured_playlists, indent=4))
# print(made_for_you)
# all_playlists = playlists["items"] + featured_playlists["items"] #+ made_for_you["items"]
# Iterate over each Spotify playlist # Iterate over each Spotify playlist
for playlist in all_playlists: for playlist in all_playlists:
playlist_name = playlist["name"] playlist_name = playlist["name"]
playlist_id = playlist["id"] playlist_id = playlist["id"]
playlist_owner = playlist["owner"]["display_name"] playlist_owner = playlist["owner"]["display_name"]
logging.info(f"Processing Spotify playlist: {playlist_name} ({playlist_owner})") logger.info(f"Processing Spotify playlist: {playlist_name} ({playlist_owner})")
emby_playlist_name = f"{playlist_name} ({playlist_owner})" emby_playlist_name = f"{playlist_name} ({playlist_owner})"
@ -77,14 +78,14 @@ def sync_spotify_playlists(spot, emby):
and existing_playlist["Type"] == "Playlist" and existing_playlist["Type"] == "Playlist"
): ):
emby.delete_playlist(existing_playlist['Id']) emby.delete_playlist(existing_playlist['Id'])
logging.info( logger.info(
f"Deleted existing Emby playlist: {emby_playlist_name} (ID: {existing_playlist_id})" f"Deleted existing Emby playlist: {emby_playlist_name} (ID: {existing_playlist_id})"
) )
# Create a new playlist in Emby # Create a new playlist in Emby
try: try:
emby_playlist = emby.create_playlist(emby_playlist_name, 'Audio') emby_playlist = emby.create_playlist(emby_playlist_name, 'Audio')
logging.info( logger.info(
f"Created Emby playlist: {playlist_name} (ID: {emby_playlist["Id"]})" f"Created Emby playlist: {playlist_name} (ID: {emby_playlist["Id"]})"
) )
# Get the playlist image from Spotify # Get the playlist image from Spotify
@ -92,33 +93,42 @@ def sync_spotify_playlists(spot, emby):
if playlist_image_data: if playlist_image_data:
try: try:
emby.upload_image_data(emby_playlist['Id'], playlist_image_data) emby.upload_image_data(emby_playlist['Id'], playlist_image_data)
logging.info(f"Uploaded playlist cover image for '{playlist_name}'") logger.info(f"Uploaded playlist cover image for '{playlist_name}'")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.warning(f"Error uploading playlist cover image: {str(e)}") logger.warning(f"Error uploading playlist cover image: {str(e)}")
else: else:
logging.warning(f"No playlist cover image found for '{playlist_name}'") logger.warning(f"No playlist cover image found for '{playlist_name}'")
except (requests.exceptions.RequestException, KeyError) as e: except (requests.exceptions.RequestException, KeyError) as e:
logging.error(f"Error creating Emby playlist: {playlist_name}") logger.error(f"Error creating Emby playlist: {playlist_name}")
logging.error(f"Error message: {str(e)}") logger.error(f"Error message: {str(e)}")
# Get the tracks in the Spotify playlist # Get the tracks in the Spotify playlist
tracks = spot.get_playlist_tracks(playlist_id) tracks = spot.get_playlist_tracks(playlist_id)
logging.info(f"Processing {len(tracks)} tracks in Spotify playlist") logger.info(f"Processing {len(tracks)} tracks in Spotify playlist")
# Iterate over each track in the Spotify playlist # Iterate over each track in the Spotify playlist
added_tracks = 0 added_tracks = 0
unmatched_tracks = [] unmatched_tracks = []
for track in tracks: for track in tracks:
# skil if track is not available
if track["track"] is None:
logger.warning(f"Track is not available: {track}")
continue
if track["track"]["name"] is None:
logger.warning(f"Track name is None: {track}")
continue
track_name = track["track"]["name"] track_name = track["track"]["name"]
artist_name = track["track"]["artists"][0]["name"] artist_name = track["track"]["artists"][0]["name"]
album_name = track["track"]["album"]["name"] album_name = track["track"]["album"]["name"]
if album_name is None:
album_name = "Unknown Album"
if try_match_and_add(track_name, artist_name, emby_playlist, emby, spot): if try_match_and_add(track_name, artist_name, emby_playlist, emby, spot):
added_tracks += 1 added_tracks += 1
continue continue
@ -130,9 +140,9 @@ def sync_spotify_playlists(spot, emby):
added_tracks += 1 added_tracks += 1
continue continue
logging.warning(f"No match found for '{track_name}' by {artist_name} in Emby") logger.warning(f"No match found for '{track_name}' by {artist_name} in Emby / clean: {clean_track_name}")
logging.warning(f"Cleaned track name: {clean_track_name}") # logging.warning(f"Cleaned track name: {clean_track_name}")
logging.warning(f"Cleaned artist name: {clean_artist_name}") # logging.warning(f"Cleaned artist name: {clean_artist_name}")
unmatched_tracks.append((playlist_name, track_name, artist_name, album_name)) unmatched_tracks.append((playlist_name, track_name, artist_name, album_name))
@ -143,7 +153,7 @@ def sync_spotify_playlists(spot, emby):
# Calculate the match percentage for the current playlist # Calculate the match percentage for the current playlist
if len(tracks) > 0: if len(tracks) > 0:
match_percentage = (added_tracks / len(tracks)) * 100 match_percentage = (added_tracks / len(tracks)) * 100
logging.info(f"Match percentage for playlist '{playlist_name}': {match_percentage:.2f}%") logger.info(f"Match percentage for playlist '{playlist_name}': {match_percentage:.2f}%")
added_tracks = 0 added_tracks = 0
@ -151,8 +161,6 @@ def sync_spotify_playlists(spot, emby):
conn.close() conn.close()
if __name__ == "__main__": if __name__ == "__main__":
spot = SpotifyClient(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET, Config.SPOTIFY_REDIRECT_URI, spot = SpotifyClient(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET, Config.SPOTIFY_REDIRECT_URI,
Config.SPOTIFY_SCOPE) Config.SPOTIFY_SCOPE)

View File

@ -0,0 +1,71 @@
# codebase/emby-scripts/src/actions/sync_spotify_to_navidrome_playlists.py
import requests
from src.config import Config
from src.clients.spotify_client import SpotifyClient
from src.clients.navidrome_client import NavidromeClient
from src.utils.logger import logger
def try_match_and_add(track_name, artist_name, navidrome_playlist, spotify, navidrome):
spotify_track = {"name": track_name, "artist": artist_name}
try:
navidrome_track = navidrome.search_track(track_name, artist_name)
if navidrome_track:
if navidrome.match_song(spotify_track, navidrome_track):
navidrome.add_track_to_playlist(navidrome_playlist['id'], navidrome_track['id'])
logger.info(f"Added '{track_name}' by {artist_name} to Navidrome playlist")
return True
else:
logger.warning(f"Found track but it didn't match closely enough: '{track_name}' by {artist_name}")
else:
logger.warning(f"No match found for '{track_name}' by {artist_name} in Navidrome")
except requests.exceptions.RequestException as e:
logger.warning(f"Error adding track to Navidrome playlist: {track_name}")
logger.warning(f"Error message: {str(e)}")
return False
def sync_navidrome_playlists(spotify, navidrome):
spotify_playlists = spotify.get_playlists()
for spotify_playlist in spotify_playlists['items']:
playlist_name = spotify_playlist["name"]
playlist_id = spotify_playlist["id"]
logger.info(f"Processing Spotify playlist: {playlist_name}")
# Check if playlist exists in Navidrome, create if not
navidrome_playlist = navidrome.get_playlist_by_name(playlist_name)
if not navidrome_playlist:
navidrome_playlist = navidrome.create_playlist(playlist_name)
logger.info(f"Created Navidrome playlist: {playlist_name}")
else:
# Clear existing tracks in Navidrome playlist
navidrome.clear_playlist(navidrome_playlist['id'])
logger.info(f"Cleared existing tracks in Navidrome playlist: {playlist_name}")
# Get tracks from Spotify playlist
spotify_tracks = spotify.get_playlist_tracks(playlist_id)
added_tracks = 0
for item in spotify_tracks:
track = item['track']
track_name = track['name']
artist_name = track['artists'][0]['name'] # Assuming the first artist
if try_match_and_add(track_name, artist_name, navidrome_playlist, spotify, navidrome):
added_tracks += 1
# Calculate the match percentage for the current playlist
if len(spotify_tracks) > 0:
match_percentage = (added_tracks / len(spotify_tracks)) * 100
logger.info(f"Match percentage for playlist '{playlist_name}': {match_percentage:.2f}%")
if __name__ == "__main__":
spotify = SpotifyClient(Config.SPOTIFY_CLIENT_ID, Config.SPOTIFY_CLIENT_SECRET, Config.SPOTIFY_REDIRECT_URI,
Config.SPOTIFY_SCOPE)
navidrome = NavidromeClient(Config.NAVIDROME_URL, Config.NAVIDROME_USERNAME, Config.NAVIDROME_PASSWORD)
sync_navidrome_playlists(spotify, navidrome)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,91 @@
# codebase/emby-scripts/src/clients/navidrome_client.py
import requests
import hashlib
import time
import random
import string
from urllib.parse import urlencode
import logging
from fuzzywuzzy import fuzz
from src.utils.string import StringUtils
class NavidromeClient:
def __init__(self, base_url, username, password):
self.base_url = base_url
self.username = username
self.password = password
self.salt = self.generate_salt()
self.logger = logging.getLogger(__name__)
def generate_salt(self, length=6):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
def get_auth_params(self):
t = int(time.time() * 1000)
token = hashlib.md5(f"{self.password}{self.salt}".encode()).hexdigest()
return {
'u': self.username,
't': token,
's': self.salt,
'v': '1.16.1',
'c': 'myapp',
'f': 'json'
}
def make_request(self, endpoint, params=None):
url = f"{self.base_url}/rest/{endpoint}"
auth_params = self.get_auth_params()
if params:
auth_params.update(params)
response = requests.get(url, params=auth_params)
response.raise_for_status()
return response.json()
def get_playlists(self):
response = self.make_request('getPlaylists')
return response.get('subsonic-response', {}).get('playlists', {}).get('playlist', [])
def get_playlist_by_name(self, name):
playlists = self.get_playlists()
return next((pl for pl in playlists if pl['name'] == name), None)
def create_playlist(self, name):
response = self.make_request('createPlaylist', {'name': name})
return response.get('subsonic-response', {}).get('playlist')
def clear_playlist(self, playlist_id):
self.make_request('updatePlaylist', {'playlistId': playlist_id, 'songIdToRemove': ''})
def search_track(self, title, artist):
response = self.make_request('search3', {'query': f"{title} {artist}"})
songs = response.get('subsonic-response', {}).get('searchResult3', {}).get('song', [])
best_match = None
best_score = 0
for song in songs:
title_score = fuzz.ratio(StringUtils.clean_string(title), StringUtils.clean_string(song['title']))
artist_score = fuzz.ratio(StringUtils.clean_string(artist), StringUtils.clean_string(song['artist']))
avg_score = (title_score + artist_score) / 2
if avg_score > best_score:
best_score = avg_score
best_match = song
return best_match if best_score > 80 else None
def add_track_to_playlist(self, playlist_id, track_id):
self.make_request('updatePlaylist', {'playlistId': playlist_id, 'songIdToAdd': track_id})
def match_song(self, spotify_track, navidrome_track):
spotify_title = StringUtils.clean_string(spotify_track.get("name", "").lower())
spotify_artist = StringUtils.clean_string(spotify_track.get("artist", "").lower())
navidrome_title = StringUtils.clean_string(navidrome_track.get("title", "").lower())
navidrome_artist = StringUtils.clean_string(navidrome_track.get("artist", "").lower())
title_ratio = fuzz.ratio(spotify_title, navidrome_title)
artist_ratio = fuzz.ratio(spotify_artist, navidrome_artist)
# Adjust these thresholds as needed
return title_ratio > 80 and artist_ratio > 80

View File

@ -1,10 +1,9 @@
import logging
import re import re
import requests import requests
import spotipy import spotipy
from fuzzywuzzy import fuzz from fuzzywuzzy import fuzz
from spotipy import SpotifyOAuth from spotipy import SpotifyOAuth
from src.utils.logger import logger
from src.utils.string import StringUtils from src.utils.string import StringUtils
@ -85,7 +84,7 @@ class SpotifyClient:
return False return False
except Exception as e: except Exception as e:
logging.error(f"Error matching song: {str(e)}") logger.error(f"Error matching song: {str(e)}")
return False return False
def get_playlist_tracks(self, playlist_id): def get_playlist_tracks(self, playlist_id):
@ -125,6 +124,44 @@ class SpotifyClient:
return self.sp.featured_playlists()["playlists"] return self.sp.featured_playlists()["playlists"]
pass pass
def get_categorys(self):
return self.sp.categories(country="US")["categories"]
def get_category_by_name(self, name):
for category in self.sp.categories(country="US")["categories"]["items"]:
logger.info(f"Checking category: {category['name']} == {name}")
if category["name"].lower() == name.lower():
logger.info(f"Found category: {category['name']}")
return category
return None
def get_category_playlists_by_name(self, name):
category = self.get_category_by_name(name)
if category:
logger.info(f"Getting playlists from category: {category['name']}")
return self.sp.category_playlists(category["id"])["playlists"]
return None
def get_made_for_you(self):
# get the category id for made for you
playlists = self.get_category_playlists_by_name("Made for You")
logger.info(f"Made for you playlists: {playlists}")
# pretty print all the playlists
# for playlist in playlists['items']:
# logger.debug(f"Playlist name: {playlist['name']}")
# logger.debug(f"Playlist description: {playlist['description']}")
# logger.debug(f"Playlist owner: {playlist['owner']['display_name']}")
# logger.debug(f"Playlist tracks: {playlist['tracks']['total']}")
# logger.debug(f"Playlist images: {playlist['images']}")
# logger.debug(f"Playlist collaborative: {playlist['collaborative']}")
# get the tracks in the playlists
return playlists
def remove_parentheses(text): def remove_parentheses(text):
if text: if text:
return re.sub(r'\([^)]*\)', '', text).strip() return re.sub(r'\([^)]*\)', '', text).strip()
@ -135,7 +172,7 @@ def clean_title(title):
# Convert to lowercase # Convert to lowercase
title = title.lower() title = title.lower()
logging.info(f"Current title: {title}") # logging.info(f"Current title: {title}")
# Remove text after featuring, feat, ft, etc. # Remove text after featuring, feat, ft, etc.
patterns = [ patterns = [
@ -190,5 +227,5 @@ def clean_title(title):
# Remove any extra whitespace # Remove any extra whitespace
title = ' '.join(title.split()) title = ' '.join(title.split())
logging.info(f"Cleaned Title: {title}") # logging.info(f"Cleaned Title: {title}")
return title.strip() return title.strip()

View File

@ -29,3 +29,7 @@ class Config:
SONARR_URL = os.getenv('SONARR_URL') SONARR_URL = os.getenv('SONARR_URL')
LOCAL_SONARR_ROOT_PATH = os.getenv('LOCAL_SONARR_ROOT_PATH') # The local path that gets the user to the same path that sonarr uses for root folder. LOCAL_SONARR_ROOT_PATH = os.getenv('LOCAL_SONARR_ROOT_PATH') # The local path that gets the user to the same path that sonarr uses for root folder.
NAVIDROME_URL = os.getenv('NAVIDROME_URL')
NAVIDROME_USERNAME = os.getenv('NAVIDROME_USERNAME')
NAVIDROME_PASSWORD = os.getenv('NAVIDROME_PASSWORD')

View File

@ -5,7 +5,7 @@ from src.config import Config
from src.clients.emby_client import EmbyClient from src.clients.emby_client import EmbyClient
from src.clients.spotify_client import SpotifyClient from src.clients.spotify_client import SpotifyClient
from src.services.playlist_service import PlaylistService from src.services.playlist_service import PlaylistService
from src.actions.sync_spotify_playlists import sync_spotify_playlists from src.actions.sync_spotify_to_emby_playlists import sync_spotify_playlists
from src.actions.find_unmatched_songs import find_unmatched_songs from src.actions.find_unmatched_songs import find_unmatched_songs
from src.actions.delete_duplicate_playlists import delete_duplicate_playlists from src.actions.delete_duplicate_playlists import delete_duplicate_playlists
from src.actions.copy_emby_playlist_to_users import copy_playlists_to_users from src.actions.copy_emby_playlist_to_users import copy_playlists_to_users

Binary file not shown.

Binary file not shown.

45
src/utils/logger.py Normal file
View File

@ -0,0 +1,45 @@
# codebase/emby-scripts/src/utils/logger.py
import logging
from colorama import Fore, Style, init
init(autoreset=True)
class ColoredFormatter(logging.Formatter):
COLORS = {
'DEBUG': Fore.BLUE,
'WARNING': Fore.YELLOW,
'INFO': Fore.WHITE,
'ERROR': Fore.RED,
'CRITICAL': Fore.RED + Style.BRIGHT
}
def format(self, record):
log_color = self.COLORS.get(record.levelname, Fore.WHITE)
log_message = super().format(record)
return f"{log_color}{log_message}{Style.RESET_ALL}"
def setup_logger(name='root', level=logging.INFO):
logger = logging.getLogger(name)
logger.setLevel(level)
# Remove all handlers associated with the logger object
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# Create console handler and set formatter
ch = logging.StreamHandler()
formatter = ColoredFormatter("%(asctime)s - %(levelname)s - %(message)s")
ch.setFormatter(formatter)
logger.addHandler(ch)
return logger
# Create a default logger instance
logger = setup_logger()
debug = logger.debug
info = logger.info
warning = logger.warning
error = logger.error
critical = logger.critical

View File

@ -8,9 +8,15 @@ class StringUtils:
""" """
return re.sub(r"[^a-zA-Z0-9\s]", "", s).lower() return re.sub(r"[^a-zA-Z0-9\s]", "", s).lower()
@staticmethod
def remove_special_characters(text): def remove_special_characters(text):
# Remove special characters, brackets, parentheses, and their contents # Remove special characters, brackets, parentheses, and their contents
if text is None:
return ""
if not isinstance(text, str):
text = str(text)
cleaned_text = re.sub(r"[\[\]\(\)]*", "", text) cleaned_text = re.sub(r"[\[\]\(\)]*", "", text)
cleaned_text = re.sub(r"[^a-zA-Z0-9\s]", "", cleaned_text) cleaned_text = re.sub(r"[^a-zA-Z0-9\s]", "", cleaned_text)
return cleaned_text return cleaned_text.strip()