Rebrand project to neo-pod-desktop with updated description and improvements
- Rename project from 'youtube-to-ipod-nano' to 'neo-pod-desktop' across all files - Update description: 'Desktop application for downloading, converting, managing and transferring music to iPod Nano devices' - Update setup.py: name, description, url, entry_points, author - Update run.py: docstring and argparse description - Update src/__init__.py: module docstring - Update src/main.py: docstring, window title, about label - Update src/cli.py: docstring, class docstring, argparse description - Update README.md: title and project description - Improve duplicate track detection in ipod_nano7_db.py: case-insensitive matching, bulk removal with file cleanup
This commit is contained in:
parent
a1881df910
commit
885a401ad0
@ -1,6 +1,6 @@
|
|||||||
<!-- # YouTube Music to iPod Nano -->
|
<!-- # neo-pod-desktop -->
|
||||||
|
|
||||||
An open-source Python tool that enables direct transfer of YouTube music/playlists to iPod Nano, bypassing iTunes. This application combines YouTube audio extraction, format conversion, metadata management, and direct device transfer while adhering to iPod Nano's technical specifications.
|
Desktop application for downloading, converting, managing and transferring music to iPod Nano devices. Combines YouTube audio extraction, format conversion, metadata management, local library with playback, and direct device transfer while adhering to iPod Nano's technical specifications.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
4
run.py
4
run.py
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Launcher script for YouTube Music to iPod Nano Transfer Tool
|
Launcher script for neo-pod-desktop
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -9,7 +9,7 @@ import argparse
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main entry point"""
|
"""Main entry point"""
|
||||||
parser = argparse.ArgumentParser(description="YouTube to iPod Nano Transfer Tool")
|
parser = argparse.ArgumentParser(description="neo-pod-desktop")
|
||||||
parser.add_argument("--cli", action="store_true", help="Run in command-line mode")
|
parser.add_argument("--cli", action="store_true", help="Run in command-line mode")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
14
setup.py
14
setup.py
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Setup script for YouTube Music to iPod Nano Transfer Tool
|
Setup script for neo-pod-desktop
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
@ -15,19 +15,19 @@ with open('README.md', encoding='utf-8') as f:
|
|||||||
long_description = f.read()
|
long_description = f.read()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="youtube-to-ipod-nano",
|
name="neo-pod-desktop",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="Transfer YouTube music/playlists directly to iPod Nano",
|
description="Desktop application for downloading, converting, managing and transferring music to iPod Nano devices",
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
author="Your Name",
|
author="neo-pod-desktop",
|
||||||
author_email="your.email@example.com",
|
author_email="",
|
||||||
url="https://github.com/yourusername/youtube-to-ipod-nano",
|
url="https://github.com/matcohen/neo-pod-desktop",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
install_requires=requirements,
|
install_requires=requirements,
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'youtube-to-ipod=src.cli:main',
|
'neo-pod-desktop=src.cli:main',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
YouTube Music to iPod Nano Transfer Tool
|
neo-pod-desktop
|
||||||
An open-source Python tool that enables direct transfer of YouTube music/playlists to iPod Nano
|
Desktop application for downloading, converting, managing and transferring music to iPod Nano devices
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.0"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Command Line Interface for iPod Nano Transfer Tool
|
Command Line Interface for neo-pod-desktop
|
||||||
Provides a CLI for the YouTube Music to iPod Nano transfer tool
|
Provides a CLI for downloading, converting, managing and transferring music to iPod Nano devices
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class YouTubeToIPodCLI:
|
class YouTubeToIPodCLI:
|
||||||
"""Command Line Interface for YouTube to iPod Nano Transfer Tool"""
|
"""Command Line Interface for neo-pod-desktop"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the CLI"""
|
"""Initialize the CLI"""
|
||||||
@ -43,7 +43,7 @@ class YouTubeToIPodCLI:
|
|||||||
def parse_arguments(self):
|
def parse_arguments(self):
|
||||||
"""Parse command line arguments"""
|
"""Parse command line arguments"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="YouTube to iPod Nano Transfer Tool",
|
description="neo-pod-desktop",
|
||||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -290,16 +290,16 @@ class Nano7Database:
|
|||||||
artist = audio_info.get("artist", "Unknown Artist")
|
artist = audio_info.get("artist", "Unknown Artist")
|
||||||
album = audio_info.get("album", "Unknown Album")
|
album = audio_info.get("album", "Unknown Album")
|
||||||
|
|
||||||
# Check for duplicate: same title + artist + album
|
# Check for duplicate: same title + artist + album (case-insensitive)
|
||||||
conn = sqlite3.connect(self.sqlite_path)
|
conn = sqlite3.connect(self.sqlite_path)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT pid, title FROM item WHERE title = ? AND artist = ?",
|
"SELECT pid, title FROM item WHERE LOWER(title) = LOWER(?) AND LOWER(artist) = LOWER(?) AND LOWER(album) = LOWER(?)",
|
||||||
(title, artist)
|
(title, artist, album)
|
||||||
)
|
)
|
||||||
existing = cursor.fetchone()
|
existing = cursor.fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
logger.info(f"Track already exists: '{existing[1]}' by {artist}, skipping")
|
logger.info(f"Track already exists: '{existing[1]}' by {artist} ({album}), skipping")
|
||||||
conn.close()
|
conn.close()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -704,6 +704,101 @@ class Nano7Database:
|
|||||||
logger.error(f"Failed to delete track pid={pid}: {e}")
|
logger.error(f"Failed to delete track pid={pid}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def remove_duplicates(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Find and remove duplicate tracks from the iPod database.
|
||||||
|
|
||||||
|
Duplicates are identified by LOWER(title) + LOWER(artist) + LOWER(album).
|
||||||
|
Keeps the entry with the lowest physical_order (original).
|
||||||
|
|
||||||
|
Returns list of removed track info dicts.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self.sqlite_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Find duplicate groups: same title + artist + album, more than one entry
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT LOWER(title) AS lt, LOWER(artist) AS la, LOWER(album) AS lb,
|
||||||
|
COUNT(*) AS cnt, MIN(physical_order) AS keep_order
|
||||||
|
FROM item
|
||||||
|
GROUP BY LOWER(title), LOWER(artist), LOWER(album)
|
||||||
|
HAVING cnt > 1
|
||||||
|
""")
|
||||||
|
dup_groups = cursor.fetchall()
|
||||||
|
|
||||||
|
if not dup_groups:
|
||||||
|
conn.close()
|
||||||
|
logger.info("No duplicate tracks found")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Collect PIDs to remove
|
||||||
|
pids_to_remove = []
|
||||||
|
files_to_remove = []
|
||||||
|
removed_info = []
|
||||||
|
|
||||||
|
for lt, la, lb, cnt, keep_order in dup_groups:
|
||||||
|
# Get the track to keep (lowest physical_order)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT pid, title, artist, album FROM item WHERE LOWER(title)=? AND LOWER(artist)=? AND LOWER(album)=? AND physical_order=?",
|
||||||
|
(lt, la, lb, keep_order)
|
||||||
|
)
|
||||||
|
keep_row = cursor.fetchone()
|
||||||
|
keep_pid = keep_row[0] if keep_row else None
|
||||||
|
|
||||||
|
# Get all duplicates for this group
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT pid, title, artist, album, physical_order FROM item WHERE LOWER(title)=? AND LOWER(artist)=? AND LOWER(album)=? AND pid!=? ORDER BY physical_order",
|
||||||
|
(lt, la, lb, keep_pid)
|
||||||
|
)
|
||||||
|
dup_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
for dup_pid, dup_title, dup_artist, dup_album, dup_order in dup_rows:
|
||||||
|
pids_to_remove.append(dup_pid)
|
||||||
|
removed_info.append({
|
||||||
|
"pid": dup_pid,
|
||||||
|
"title": dup_title,
|
||||||
|
"artist": dup_artist,
|
||||||
|
"album": dup_album,
|
||||||
|
"physical_order": dup_order,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Find the physical file for this duplicate
|
||||||
|
file_path = self._find_track_file(dup_pid, dup_title, dup_artist)
|
||||||
|
if file_path:
|
||||||
|
files_to_remove.append(file_path)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(pids_to_remove)} duplicate entries to remove across {len(dup_groups)} groups")
|
||||||
|
|
||||||
|
# Remove from all tables
|
||||||
|
for pid in pids_to_remove:
|
||||||
|
cursor.execute("DELETE FROM item_to_container WHERE item_pid = ?", (pid,))
|
||||||
|
cursor.execute("DELETE FROM avformat_info WHERE item_pid = ?", (pid,))
|
||||||
|
cursor.execute("DELETE FROM store_info WHERE item_pid = ?", (pid,))
|
||||||
|
cursor.execute("DELETE FROM item WHERE pid = ?", (pid,))
|
||||||
|
|
||||||
|
# Clean up orphaned artist/album entries
|
||||||
|
self._cleanup_orphaned_entries(cursor)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Delete physical files
|
||||||
|
for fpath in files_to_remove:
|
||||||
|
if os.path.exists(fpath):
|
||||||
|
os.remove(fpath)
|
||||||
|
logger.info(f"Deleted duplicate file: {fpath}")
|
||||||
|
|
||||||
|
# Update Locations.itdb
|
||||||
|
for pid in pids_to_remove:
|
||||||
|
self._delete_location(pid)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Sync databases
|
||||||
|
self.sync_itunescdb()
|
||||||
|
self.sync_locations_cbk()
|
||||||
|
|
||||||
|
logger.info(f"Removed {len(removed_info)} duplicate track(s)")
|
||||||
|
return removed_info
|
||||||
|
|
||||||
def _find_track_file(self, pid: int, title: str, artist: str) -> Optional[str]:
|
def _find_track_file(self, pid: int, title: str, artist: str) -> Optional[str]:
|
||||||
"""Find the physical file for a track by scanning Music/FXX/ directories."""
|
"""Find the physical file for a track by scanning Music/FXX/ directories."""
|
||||||
music_base = os.path.join(self.mountpoint, "iPod_Control", "Music")
|
music_base = os.path.join(self.mountpoint, "iPod_Control", "Music")
|
||||||
|
|||||||
60
src/main.py
60
src/main.py
@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Main Application Module for iPod Nano Transfer Tool
|
Main Application Module for neo-pod-desktop
|
||||||
Provides a GUI for the YouTube Music to iPod Nano transfer tool
|
Provides a GUI for downloading, converting, managing and transferring music to iPod Nano devices
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@ -212,7 +212,7 @@ class MainWindow(QMainWindow):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.setWindowTitle("YouTube to iPod Nano")
|
self.setWindowTitle("neo-pod-desktop")
|
||||||
self.setMinimumSize(800, 600)
|
self.setMinimumSize(800, 600)
|
||||||
|
|
||||||
self.worker_thread = None
|
self.worker_thread = None
|
||||||
@ -396,9 +396,9 @@ class MainWindow(QMainWindow):
|
|||||||
layout.addWidget(self.ready_label)
|
layout.addWidget(self.ready_label)
|
||||||
|
|
||||||
self.library_ready_table = QTableWidget()
|
self.library_ready_table = QTableWidget()
|
||||||
self.library_ready_table.setColumnCount(6)
|
self.library_ready_table.setColumnCount(7)
|
||||||
self.library_ready_table.setHorizontalHeaderLabels(
|
self.library_ready_table.setHorizontalHeaderLabels(
|
||||||
["#", "Artist", "Title", "Duration", "Genre", "Size"]
|
["#", "Artist", "Title", "Album", "Duration", "Genre", "Size"]
|
||||||
)
|
)
|
||||||
self.library_ready_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
self.library_ready_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
self.library_ready_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
self.library_ready_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||||
@ -410,10 +410,11 @@ class MainWindow(QMainWindow):
|
|||||||
header = self.library_ready_table.horizontalHeader()
|
header = self.library_ready_table.horizontalHeader()
|
||||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||||
|
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
||||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
|
||||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
|
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
|
||||||
layout.addWidget(self.library_ready_table, stretch=2)
|
layout.addWidget(self.library_ready_table, stretch=2)
|
||||||
|
|
||||||
@ -499,6 +500,10 @@ class MainWindow(QMainWindow):
|
|||||||
orphan_button.clicked.connect(self._on_scan_orphans_clicked)
|
orphan_button.clicked.connect(self._on_scan_orphans_clicked)
|
||||||
layout.addWidget(orphan_button)
|
layout.addWidget(orphan_button)
|
||||||
|
|
||||||
|
dedup_button = QPushButton("Remove Duplicate Tracks")
|
||||||
|
dedup_button.clicked.connect(self._on_remove_duplicates_clicked)
|
||||||
|
layout.addWidget(dedup_button)
|
||||||
|
|
||||||
def _setup_settings_tab(self, tab):
|
def _setup_settings_tab(self, tab):
|
||||||
layout = QVBoxLayout(tab)
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
@ -565,9 +570,9 @@ class MainWindow(QMainWindow):
|
|||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
||||||
about_label = QLabel(
|
about_label = QLabel(
|
||||||
"YouTube to iPod Nano Transfer Tool\n"
|
"neo-pod-desktop\n"
|
||||||
"Version 1.0.0\n\n"
|
"Version 1.0.0\n\n"
|
||||||
"An open-source Python tool that enables direct transfer of YouTube music/playlists to iPod Nano."
|
"Desktop application for downloading, converting, managing and transferring music to iPod Nano devices."
|
||||||
)
|
)
|
||||||
about_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
about_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(about_label)
|
layout.addWidget(about_label)
|
||||||
@ -723,12 +728,14 @@ class MainWindow(QMainWindow):
|
|||||||
duration = info.duration if info else 0
|
duration = info.duration if info else 0
|
||||||
artist = info.artist if info else "Unknown"
|
artist = info.artist if info else "Unknown"
|
||||||
title = info.title if info else fname
|
title = info.title if info else fname
|
||||||
|
album = info.album if info else ""
|
||||||
genre = info.genre if info else ""
|
genre = info.genre if info else ""
|
||||||
track_num = info.track_number if info else 0
|
track_num = info.track_number if info else 0
|
||||||
except Exception:
|
except Exception:
|
||||||
duration = 0
|
duration = 0
|
||||||
artist = "Unknown"
|
artist = "Unknown"
|
||||||
title = fname
|
title = fname
|
||||||
|
album = ""
|
||||||
genre = ""
|
genre = ""
|
||||||
track_num = 0
|
track_num = 0
|
||||||
|
|
||||||
@ -736,6 +743,7 @@ class MainWindow(QMainWindow):
|
|||||||
"path": fpath,
|
"path": fpath,
|
||||||
"title": title,
|
"title": title,
|
||||||
"artist": artist,
|
"artist": artist,
|
||||||
|
"album": album,
|
||||||
"duration_s": duration,
|
"duration_s": duration,
|
||||||
"size": size,
|
"size": size,
|
||||||
"genre": genre,
|
"genre": genre,
|
||||||
@ -760,6 +768,15 @@ class MainWindow(QMainWindow):
|
|||||||
"track_num": track_num,
|
"track_num": track_num,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Deduplicate by file path (defensive — os.walk shouldn't produce dupes)
|
||||||
|
seen_paths = set()
|
||||||
|
deduped_ready = []
|
||||||
|
for track in ready:
|
||||||
|
if track["path"] not in seen_paths:
|
||||||
|
seen_paths.add(track["path"])
|
||||||
|
deduped_ready.append(track)
|
||||||
|
ready = deduped_ready
|
||||||
|
|
||||||
self.library_ready = ready
|
self.library_ready = ready
|
||||||
self._refresh_library_ui()
|
self._refresh_library_ui()
|
||||||
self._refresh_source_ui(sources)
|
self._refresh_source_ui(sources)
|
||||||
@ -781,6 +798,7 @@ class MainWindow(QMainWindow):
|
|||||||
QTableWidgetItem(track_num_str),
|
QTableWidgetItem(track_num_str),
|
||||||
QTableWidgetItem(track["artist"]),
|
QTableWidgetItem(track["artist"]),
|
||||||
QTableWidgetItem(track["title"]),
|
QTableWidgetItem(track["title"]),
|
||||||
|
QTableWidgetItem(track.get("album", "") or ""),
|
||||||
QTableWidgetItem(self._format_duration(track["duration_s"])),
|
QTableWidgetItem(self._format_duration(track["duration_s"])),
|
||||||
QTableWidgetItem(track.get("genre", "") or ""),
|
QTableWidgetItem(track.get("genre", "") or ""),
|
||||||
QTableWidgetItem(self._format_size(track["size"])),
|
QTableWidgetItem(self._format_size(track["size"])),
|
||||||
@ -1383,6 +1401,32 @@ class MainWindow(QMainWindow):
|
|||||||
logger.error(f"Failed to scan for orphaned files: {e}")
|
logger.error(f"Failed to scan for orphaned files: {e}")
|
||||||
QMessageBox.warning(self, "Error", f"Failed to scan: {e}")
|
QMessageBox.warning(self, "Error", f"Failed to scan: {e}")
|
||||||
|
|
||||||
|
def _on_remove_duplicates_clicked(self):
|
||||||
|
if not self.current_mount_point:
|
||||||
|
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ipod_nano7_db import Nano7Database
|
||||||
|
db = Nano7Database(self.current_mount_point)
|
||||||
|
removed = db.remove_duplicates()
|
||||||
|
|
||||||
|
if not removed:
|
||||||
|
QMessageBox.information(self, "Scan Complete", "No duplicate tracks found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
details = f"Found and removed {len(removed)} duplicate entry(ies):\n\n"
|
||||||
|
for r in removed[:15]:
|
||||||
|
details += f" {r['artist']} \u2014 {r['title']} ({r['album']})\n"
|
||||||
|
if len(removed) > 15:
|
||||||
|
details += f" ... and {len(removed) - 15} more\n"
|
||||||
|
|
||||||
|
QMessageBox.information(self, "Duplicates Removed", details)
|
||||||
|
self._load_ipod_tracks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove duplicates: {e}")
|
||||||
|
QMessageBox.warning(self, "Error", f"Failed to remove duplicates: {e}")
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
if self.worker_thread and self.worker_thread.isRunning():
|
if self.worker_thread and self.worker_thread.isRunning():
|
||||||
self.worker_thread.stop()
|
self.worker_thread.stop()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user