Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ ytmusicapi-oauth.json
venv
dist
build

browser.json
oauth.json
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,33 @@ When you run the executable, you will be prompted to enter your Spotify credenti

### Youtube Settings

If you click on "Private Playlist", you will be prompted to enter get your Youtube OAuth credentials if not already set.
To access private playlists, you need to log in to YouTube. You can use either Browser authentication or OAuth authentication. Please choose one method to log in.

![Youtube Settings](media/youtube_ui.png)
#### Browser authentication

1. Refer to [Browser authentication](https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html) to create a `browser.json` file.

2. Enter the path to the generated `browser.json` file in the **JSON File Path** field.

3. **Client ID** and **Client Secret** must be left empty.
![Youtube Settings](media/youtube_browser.png)

4. Close the dialog.

#### OAuth authentication

**Warning!** OAuth authentication is currently unavailable due to an [issue](https://github.com/sigma67/ytmusicapi/issues/813) with the ytmusicapi library!

1. Refer to the [YouTube Data API docs](https://developers.google.com/youtube/registering_an_application) and select `TVs and Limited Input devices` to create an `OAuth client ID`. You will need to obtain a `client_id` and `client_secret`.

2. Enter the generated `client_id` and `client_secret`.
![Youtube Settings](media/youtube_oauth.png)

3. Click the `Get OAuth Token` button. The YouTube OAuth token will be saved to the path specified in `JSON File Path`.

4. When the authentication is completed close the dialog.

[reference](https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html)

> [!TIP]<br>
> If you find this project useful or interesting, please consider giving it a 🌟star! It helps others discover it too!
Expand Down
Binary file added media/youtube_browser.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/youtube_oauth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed media/youtube_ui.png
Binary file not shown.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
"pyside6>=6.8.0",
"spotipy>=2.24.0",
"thefuzz>=0.22.1",
"ytmusicapi==1.10.3",
"ytmusicapi==1.11.3",
]

[tool.uv]
Expand Down
2 changes: 1 addition & 1 deletion src/ytm2spt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def get_args():
type=str,
default=None,
required=True,
help="Youtube Playlist URL or ID",
help="Youtube Playlist URL or ID (use 'LM' to import liked songs)",
Comment thread
AlphaBs marked this conversation as resolved.
)
sp_group = parser.add_mutually_exclusive_group(required=False)
sp_group.add_argument(
Expand Down
107 changes: 90 additions & 17 deletions src/ytm2spt/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@

from .transfer import transfer_playlist

SETTINGS = QSettings(QSettings.IniFormat, QSettings.UserScope, "ytm2spt", "config")
YTOAUTH_PATH = os.path.join(os.path.dirname(SETTINGS.fileName()), "oauth.json")
SETTINGS = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, "ytm2spt", "config")
DEFAULT_YTOAUTH_PATH = os.path.join(os.path.dirname(SETTINGS.fileName()), "oauth.json")


def get_ytoauth_path() -> str:
return str(SETTINGS.value("YT_OAUTH_PATH", defaultValue=DEFAULT_YTOAUTH_PATH) or DEFAULT_YTOAUTH_PATH)


def get_setting_str(key: str, default: str = "") -> str:
return str(SETTINGS.value(key, defaultValue=default) or default)


def init_settings():
Expand All @@ -25,6 +33,12 @@ def init_settings():
SETTINGS.setValue("SPOTIFY_CLIENT_SECRET", "")
if "SPOTIFY_REDIRECT_URI" not in keys:
SETTINGS.setValue("SPOTIFY_REDIRECT_URI", "http://localhost:8888/callback")
if "YT_CLIENT_ID" not in keys:
SETTINGS.setValue("YT_CLIENT_ID", "")
if "YT_CLIENT_SECRET" not in keys:
SETTINGS.setValue("YT_CLIENT_SECRET", "")
if "YT_OAUTH_PATH" not in keys:
SETTINGS.setValue("YT_OAUTH_PATH", DEFAULT_YTOAUTH_PATH)

SETTINGS.sync()

Expand Down Expand Up @@ -60,20 +74,20 @@ def __init__(self, parent=None):
layout.addRow(info_label)

self.user_id_input = QLineEdit()
self.user_id_input.setText(SETTINGS.value("SPOTIFY_USER_ID", defaultValue=""))
self.user_id_input.setText(get_setting_str("SPOTIFY_USER_ID"))
# self.user_id_input.setPlaceholderText("This is not email")
layout.addRow("User ID", self.user_id_input)

self.client_id_input = QLineEdit()
self.client_id_input.setText(SETTINGS.value("SPOTIFY_CLIENT_ID", defaultValue=""))
self.client_id_input.setText(get_setting_str("SPOTIFY_CLIENT_ID"))
layout.addRow("Client ID", self.client_id_input)

self.client_secret_input = QLineEdit()
self.client_secret_input.setText(SETTINGS.value("SPOTIFY_CLIENT_SECRET", defaultValue=""))
self.client_secret_input.setText(get_setting_str("SPOTIFY_CLIENT_SECRET"))
layout.addRow("Client Secret", self.client_secret_input)

self.redirect_uri_input = QLineEdit()
self.redirect_uri_input.setText(SETTINGS.value("SPOTIFY_REDIRECT_URI", defaultValue=""))
self.redirect_uri_input.setText(get_setting_str("SPOTIFY_REDIRECT_URI"))
layout.addRow("Redirect URI", self.redirect_uri_input)

self.save_button = QPushButton("Save")
Expand All @@ -91,6 +105,9 @@ def save_settings(self):
SETTINGS.sync()
self.close()

def closeEvent(self, event):
super().closeEvent(event)


class YouTubeSettingsDialog(QDialog):
def __init__(self, parent=None):
Expand All @@ -107,19 +124,50 @@ def __init__(self, parent=None):
self.oauth_button.clicked.connect(self.get_oauth_token)
layout.addWidget(self.oauth_button)

form_layout = QFormLayout()
self.yt_oauth_path_input = QLineEdit()
self.yt_oauth_path_input.setText(get_ytoauth_path())
form_layout.addRow("JSON File Path", self.yt_oauth_path_input)
self.yt_client_id_input = QLineEdit()
self.yt_client_id_input.setText(get_setting_str("YT_CLIENT_ID"))
form_layout.addRow("Client ID", self.yt_client_id_input)
self.yt_client_secret_input = QLineEdit()
self.yt_client_secret_input.setText(get_setting_str("YT_CLIENT_SECRET"))
form_layout.addRow("Client Secret", self.yt_client_secret_input)
layout.addLayout(form_layout)

self.message_label = QLabel("No OAuth token found")
self.message_label.setWordWrap(True)
self.message_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
if os.path.exists(YTOAUTH_PATH):
self.message_label.setText("OAuth token saved at " + YTOAUTH_PATH)
self.message_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
current_oauth_path = get_ytoauth_path()
if os.path.exists(current_oauth_path):
self.message_label.setText("OAuth token saved at " + current_oauth_path)
layout.addWidget(self.message_label)

def get_oauth_token(self):
setup_oauth(filepath=YTOAUTH_PATH, open_browser=True)
self.message_label.setText("OAuth token saved at " + YTOAUTH_PATH)
ytoauth_path = self.yt_oauth_path_input.text().strip() or DEFAULT_YTOAUTH_PATH
SETTINGS.setValue("YT_CLIENT_ID", self.yt_client_id_input.text())
SETTINGS.setValue("YT_CLIENT_SECRET", self.yt_client_secret_input.text())
SETTINGS.setValue("YT_OAUTH_PATH", ytoauth_path)
SETTINGS.sync()
setup_oauth(
client_id=self.yt_client_id_input.text(),
client_secret=self.yt_client_secret_input.text(),
filepath=ytoauth_path,
open_browser=True
)
self.message_label.setText("OAuth token saved at " + ytoauth_path)
# Qt sleep 3 seconds
QtCore.QTimer.singleShot(3000, self.close)

def closeEvent(self, event):
ytoauth_path = self.yt_oauth_path_input.text().strip() or DEFAULT_YTOAUTH_PATH
SETTINGS.setValue("YT_CLIENT_ID", self.yt_client_id_input.text())
SETTINGS.setValue("YT_CLIENT_SECRET", self.yt_client_secret_input.text())
SETTINGS.setValue("YT_OAUTH_PATH", ytoauth_path)
SETTINGS.sync()
super().closeEvent(event)


class MainWindow(QMainWindow):
def __init__(self):
Expand Down Expand Up @@ -149,9 +197,15 @@ def __init__(self):
yt_layout = QVBoxLayout(yt_group)
yt_layout.addWidget(QLabel("Playlist URL or ID"))
self.yt_input = QLineEdit()
self.prev_yt_value = ""
self.prev_yt_private_checked = False
self.yt_input.textChanged.connect(self.update_command)
yt_layout.addWidget(self.yt_input)

self.yt_liked_checkbox = QCheckBox("Use Liked Songs (LM)")
self.yt_liked_checkbox.toggled.connect(self.yt_liked_toggled)
yt_layout.addWidget(self.yt_liked_checkbox)

self.yt_private_checkbox = QCheckBox("Private Playlist")
self.yt_private_checkbox.stateChanged.connect(self.yt_private_toggled)
yt_layout.addWidget(self.yt_private_checkbox)
Expand Down Expand Up @@ -265,7 +319,7 @@ def update_command(self):
command += " -n"

if self.yt_private_checkbox.isChecked():
command += f' -ytauth "{YTOAUTH_PATH}"'
command += f' -ytauth "{get_ytoauth_path()}"'

if self.other_group.isChecked():
if self.dryrun_checkbox.isChecked():
Expand Down Expand Up @@ -297,13 +351,15 @@ def run_command(self):
spotify_arg = None
spotify_playlist_name = self.spname_input.text().strip()

youtube_oauth = YTOAUTH_PATH if self.yt_private_checkbox.isChecked() else None
youtube_oauth = get_ytoauth_path() if self.yt_private_checkbox.isChecked() else None
Comment thread
AlphaBs marked this conversation as resolved.
youtube_client_id = SETTINGS.value("YT_CLIENT_ID") if self.yt_private_checkbox.isChecked() else None
youtube_client_secret = SETTINGS.value("YT_CLIENT_SECRET") if self.yt_private_checkbox.isChecked() else None
dry_run = self.dryrun_checkbox.isChecked()
create_new = self.create_new_checkbox.isChecked()
limit = self.limit_input.value() if self.limit_input.value() > 0 else None

# Run ytm2spt
self.worker = RunCommandWorker(youtube_arg, spotify_arg, spotify_playlist_name, youtube_oauth, dry_run, create_new, limit)
self.worker = RunCommandWorker(youtube_arg, spotify_arg, spotify_playlist_name, youtube_oauth, youtube_client_id, youtube_client_secret, dry_run, create_new, limit)
self.worker.completed.connect(self.run_finished)
self.worker.error.connect(self.run_error)
self.worker.start()
Expand All @@ -329,20 +385,37 @@ def open_youtube_settings(self):
def yt_private_toggled(self):
self.update_command()
if self.yt_private_checkbox.isChecked():
if not os.path.exists(YTOAUTH_PATH):
if not os.path.exists(get_ytoauth_path()):
self.open_youtube_settings()

def yt_liked_toggled(self, checked: bool):
if checked:
self.prev_yt_value = self.yt_input.text()
self.prev_yt_private_checked = self.yt_private_checkbox.isChecked()
self.yt_input.setText("LM")
self.yt_input.setEnabled(False)
self.yt_private_checkbox.setChecked(True)
self.yt_private_checkbox.setEnabled(False)
else:
self.yt_input.setEnabled(True)
self.yt_input.setText(self.prev_yt_value)
self.yt_private_checkbox.setEnabled(True)
self.yt_private_checkbox.setChecked(self.prev_yt_private_checked)
self.update_command()


class RunCommandWorker(QThread):
completed = Signal()
error = Signal(str)

def __init__(self, youtube_arg, spotify_arg, spotify_playlist_name, youtube_oauth, dry_run, create_new, limit):
def __init__(self, youtube_arg, spotify_arg, spotify_playlist_name, youtube_oauth, youtube_client_id, youtube_client_secret, dry_run, create_new, limit):
super().__init__()
self.youtube_arg = youtube_arg
self.spotify_arg = spotify_arg
self.spotify_playlist_name = spotify_playlist_name
self.youtube_oauth = youtube_oauth
self.youtube_client_id = youtube_client_id
self.youtube_client_secret = youtube_client_secret
self.dry_run = dry_run
self.create_new = create_new
self.limit = limit
Expand All @@ -353,7 +426,7 @@ def run(self):
os.environ["SPOTIFY_CLIENT_ID"] = SETTINGS.value("SPOTIFY_CLIENT_ID")
os.environ["SPOTIFY_CLIENT_SECRET"] = SETTINGS.value("SPOTIFY_CLIENT_SECRET")
os.environ["SPOTIFY_REDIRECT_URI"] = SETTINGS.value("SPOTIFY_REDIRECT_URI")
transfer_playlist(self.youtube_arg, self.spotify_arg, self.spotify_playlist_name, self.youtube_oauth, self.dry_run, self.create_new, self.limit)
transfer_playlist(self.youtube_arg, self.spotify_arg, self.spotify_playlist_name, self.youtube_oauth, self.youtube_client_id, self.youtube_client_secret, self.dry_run, self.create_new, self.limit)
self.completed.emit()
except Exception as e:
print(e)
Expand Down
13 changes: 11 additions & 2 deletions src/ytm2spt/spotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def __init__(self):
client_id=os.environ['SPOTIFY_CLIENT_ID'],
client_secret=os.environ['SPOTIFY_CLIENT_SECRET'],
redirect_uri=os.environ['SPOTIFY_REDIRECT_URI'],
scope='playlist-read-collaborative playlist-modify-private playlist-modify-public playlist-read-private ugc-image-upload',
scope='playlist-read-collaborative playlist-modify-private playlist-modify-public playlist-read-private ugc-image-upload user-library-modify',
open_browser=open_browser,
cache_path=".spotipy_cache",
))
Expand Down Expand Up @@ -116,8 +116,17 @@ def add_song_to_playlist(self, song_uri: str, playlist_id: str = "") -> bool:
except SpotifyException as e:
self.spotify_logger.error(f"Error adding song to playlist: {e}")
return False

def add_song_to_liked(self, song_uri: str) -> bool:
try:
self.spotify.current_user_saved_tracks_add([song_uri])
self.spotify_logger.debug(f"Added Song {song_uri} to Liked Songs")
return True
except SpotifyException as e:
self.spotify_logger.error(f"Error adding song to liked songs: {e}")
return False

def set_playlist_cover(self, encoded_img: str, playlist_id: str = "") -> bool:
def set_playlist_cover(self, encoded_img: bytes, playlist_id: str = "") -> bool:
if not playlist_id:
playlist_id = self.playlist_id
try:
Expand Down
Loading