diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index beb1dd2c..fd959e71 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -67,6 +67,7 @@ jobs: verify-nbstripout: runs-on: [self-hosted, linux] + needs: changed-files if: needs.changed-files.outputs.any_python_changed == 'true' steps: - name: Checkout code diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8cb725..e39a38f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Traktor config option `backup_before_write`, (true by default, creates a backup of the nml file before each write). - `pyproject.toml` information updated (readme, license, authors, urls, classifiers). - Added support for batched remote operations, should allow to minify expensive network request. +- Improved examples. They now live in the docs under `docs/examples` (full-fledged notebooks by us) and `docs/examples/community` (less restrictive, also simple python scripts by the community) ### Changed diff --git a/docs/contribution.md b/docs/contribution.md index 6087a824..d6889858 100644 --- a/docs/contribution.md +++ b/docs/contribution.md @@ -45,6 +45,9 @@ mypy . # Strip output from notebooks (if modified) find . -name '*.ipynb' -exec nbstripout --keep-output {} + + +# Run mypy on notebooks +./.github/workflows/mypy_notebooks.sh ``` If this looks tedious you may alternatively install the diff --git a/docs/examples b/docs/examples new file mode 120000 index 00000000..785887f7 --- /dev/null +++ b/docs/examples @@ -0,0 +1 @@ +../examples/ \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md index 47c4eb72..36b29a0f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -54,6 +54,8 @@ Understand the key abstractions and notation of `plistsync`. Learn about `Tracks :::: ::::{grid-item-card} Examples +:link: examples/readme +:link-type: doc Follow step-by-step guides to see `plistsync` in action. Great for hands-on learning and testing common workflows. TODO :::: diff --git a/docs/index.md b/docs/index.md index afb0cfb4..4a2980bc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,6 +37,7 @@ getting-started.md details/core-concepts.md details/configuration.md details/advanced/index.md +examples/readme.md ``` ```{toctree} diff --git a/docs/services/plex/collections.ipynb b/docs/services/plex/collections.ipynb index c98a8f45..b33077b7 100644 --- a/docs/services/plex/collections.ipynb +++ b/docs/services/plex/collections.ipynb @@ -1,25 +1,8 @@ { "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": { - "tags": [ - "hide-cell" - ] - }, - "outputs": [], - "source": [ - "import os\n", - "\n", - "# Uremote_edite you config directory as needed\n", - "os.environ[\"PSYNC_CONFIG_DIR\"] = \"../../../config\"" - ] - }, { "cell_type": "markdown", - "id": "1", + "id": "0", "metadata": {}, "source": [ "# Collections\n", @@ -34,7 +17,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -45,7 +28,7 @@ }, { "cell_type": "markdown", - "id": "3", + "id": "2", "metadata": {}, "source": [ "### Iterating Tracks\n", @@ -56,7 +39,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -69,7 +52,7 @@ }, { "cell_type": "markdown", - "id": "5", + "id": "4", "metadata": {}, "source": [ "This can be a bit slow depending on the number of tracks you want to iterate. To speed things up you can consider preloading all tracks." @@ -78,7 +61,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -88,7 +71,7 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "6", "metadata": {}, "source": [ "### Track Lookup\n", @@ -103,7 +86,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -114,7 +97,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "8", "metadata": {}, "source": [ "Looking up tracks by `isrc` is currently a bit more involved.\n", @@ -127,7 +110,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "9", "metadata": {}, "outputs": [], "source": [ @@ -145,7 +128,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -159,7 +142,7 @@ }, { "cell_type": "markdown", - "id": "12", + "id": "11", "metadata": {}, "source": [ ":::{note}\n", @@ -172,7 +155,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "12", "metadata": {}, "source": [ "## Playlist Collection\n", @@ -187,7 +170,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -199,43 +182,42 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "14", "metadata": {}, "source": [ - "If you just want a specific playlist, you can use the library's {py:meth}`PlexLibraryCollection.get_playlist ` method to retrieve a playlist." + "If you just want a specific playlist, you can use the library's {py:meth}`PlexLibraryCollection.get_playlist ` or {py:meth}`PlexLibraryCollection.get_playlist_or_raise ` method to retrieve a playlist." ] }, { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "15", "metadata": {}, "outputs": [], "source": [ - "# Can get via namer or id\n", - "if pl := library.get_playlist(\n", + "# Returns `PlexPlaylistCollection | None`\n", + "maybe_pl = library.get_playlist(\n", " name=\"DnB Classics\"\n", " # id = 108530\n", - "):\n", - " print(f\"Found playlist: {pl.id} {pl.name} ({len(pl.tracks)} tracks)\")\n", - "else:\n", - " print(\"Playlist not found.\")" + ")\n", + "\n", + "# Raises if not found\n", + "pl = library.get_playlist_or_raise(id=108530)" ] }, { "cell_type": "markdown", - "id": "17", + "id": "16", "metadata": {}, "source": [ ":::{note}\n", "This method supports lookup by various identifiers, currently ``name=`` and ``id=``.\n", - "Lookup by name will return ``None`` if no matching playlist is found, while lookups by other identifiers will raise a ``ValueError`` if the playlist cannot be resolved.\n", ":::" ] }, { "cell_type": "markdown", - "id": "18", + "id": "17", "metadata": {}, "source": [ "### Creating playlists\n", @@ -246,14 +228,14 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "18", "metadata": {}, "outputs": [], "source": [ "from plistsync.services.plex import PlexPlaylistCollection\n", "\n", "try:\n", - " library.get_playlist(name=\"My New Playlist\").remote_delete()\n", + " library.get_playlist_or_raise(name=\"My New Playlist\").remote_delete()\n", "except:\n", " print(\"Playlist did not exist, no need to delete\")\n", "\n", @@ -269,7 +251,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "19", "metadata": {}, "source": [ "To remotly create the same playlist use the {py:meth}`PlexPlaylistCollection.remote_create ` method." @@ -278,7 +260,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -288,7 +270,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "21", "metadata": {}, "source": [ "### Updating a playlist\n", @@ -299,7 +281,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -313,7 +295,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -321,23 +303,25 @@ "\n", "# find some tracks to play around with\n", "new_tracks: list[PlexTrack] = list(\n", - " library.find_many_by_local_ids(\n", - " [\n", - " {\"plex_id\": \"111017\"},\n", - " {\"plex_id\": \"111018\"},\n", - " {\"plex_id\": \"111023\"},\n", - " {\"plex_id\": \"111024\"},\n", - " ]\n", + " filter(\n", + " None,\n", + " library.find_many_by_local_ids(\n", + " [\n", + " {\"plex_id\": \"111017\"},\n", + " {\"plex_id\": \"111018\"},\n", + " {\"plex_id\": \"111023\"},\n", + " {\"plex_id\": \"111024\"},\n", + " ]\n", + " ),\n", " )\n", - ") # type: ignore # TODO: use generic type in ABC\n", - "new_tracks = list(filter(None, new_tracks))\n", + ")\n", "new_tracks" ] }, { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -351,7 +335,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -365,7 +349,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -393,7 +377,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.3" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/docs/services/traktor/collections.ipynb b/docs/services/traktor/collections.ipynb index d9808bc0..b5854bd2 100644 --- a/docs/services/traktor/collections.ipynb +++ b/docs/services/traktor/collections.ipynb @@ -1,25 +1,8 @@ { "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": { - "tags": [ - "hide-cell" - ] - }, - "outputs": [], - "source": [ - "import os\n", - "\n", - "# Update you config directory as needed\n", - "os.environ[\"PSYNC_CONFIG_DIR\"] = \"../../../config\"" - ] - }, { "cell_type": "markdown", - "id": "1", + "id": "0", "metadata": {}, "source": [ "# Collections\n", @@ -60,7 +43,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2", + "id": "1", "metadata": {}, "outputs": [], "source": [ @@ -71,7 +54,7 @@ }, { "cell_type": "markdown", - "id": "3", + "id": "2", "metadata": {}, "source": [ ":::{note}\n", @@ -81,7 +64,7 @@ }, { "cell_type": "markdown", - "id": "4", + "id": "3", "metadata": {}, "source": [ "### Track Lookup\n", @@ -94,13 +77,13 @@ { "cell_type": "code", "execution_count": null, - "id": "5", + "id": "4", "metadata": {}, "outputs": [], "source": [ - "from pathlib import PurePosixPath, PureWindowsPath\n", + "from pathlib import PurePath, PurePosixPath, PureWindowsPath\n", "\n", - "path = PureWindowsPath(\n", + "path: PurePath = PureWindowsPath(\n", " r\"D:\\SYNC\\library\\Amoss, Fre4knc\\Watermark Volume 2\\04 Dragger [1028kbps].flac\"\n", ")\n", "path = PurePosixPath(\n", @@ -112,7 +95,7 @@ }, { "cell_type": "markdown", - "id": "6", + "id": "5", "metadata": {}, "source": [ "## Playlist Collection\n", @@ -127,7 +110,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7", + "id": "6", "metadata": {}, "outputs": [], "source": [ @@ -138,7 +121,7 @@ }, { "cell_type": "markdown", - "id": "8", + "id": "7", "metadata": {}, "source": [ "If you just want a specific playlist, you can use the library's {py:meth}`NMLCollection.get_playlist ` method to retrieve a playlist." @@ -147,7 +130,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -163,7 +146,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "9", "metadata": {}, "source": [ ":::{note}\n", @@ -173,7 +156,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "10", "metadata": {}, "source": [ "### Creating playlists\n", @@ -184,7 +167,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -200,7 +183,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "12", "metadata": {}, "source": [ "### Updating a playlist\n", @@ -211,7 +194,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -224,7 +207,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "14", "metadata": {}, "source": [ "You can add tracks to a playlist using the same context manager, again this will add the tracks when you exit the context." @@ -233,7 +216,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -253,7 +236,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "16", "metadata": {}, "source": [ "To reorder tracks in a playlist, you can change the order of the {py:attr}`NMLPlaylistCollection.tracks ` list within the context manager." @@ -262,7 +245,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -272,7 +255,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "18", "metadata": {}, "source": [ "As an alternative to the `with pl.remote_edit()` context manager, you can also use the {py:meth}`NMLPlaylistCollection.remote_upsert ` method to update the playlist in the collection with the current state or create it if it does not exist yet." @@ -281,7 +264,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -290,7 +273,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "20", "metadata": {}, "source": [ "### Deleting a playlist\n", @@ -301,7 +284,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -326,7 +309,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.3" + "version": "3.12.3" } }, "nbformat": 4, diff --git a/examples/collect_files_for_m3u.py b/examples/collect_files_for_m3u.py deleted file mode 100644 index 33578682..00000000 --- a/examples/collect_files_for_m3u.py +++ /dev/null @@ -1,68 +0,0 @@ -import shutil -from pathlib import Path - -from plistsync.logger import log - -m3u_file = Path("playlist.m3u") - - -def collect_m3u_files(m3u_file: Path): - """Collect m3u file. - - Takes a path to an m3u, reads the file paths in there, - and copies all files into a directory next to the m3u file. - """ - # Create collection directory next to the M3U file - collection_dir = m3u_file.parent / f"{m3u_file.stem}_tracks" - collection_dir.mkdir(exist_ok=True) - - log.info(f"Reading M3U file: {m3u_file}") - log.info(f"Collection directory: {collection_dir}") - - copied_count = 0 - skipped_count = 0 - - try: - with open(m3u_file, encoding="utf-8") as f: - for line_num, line in enumerate(f, 1): - line = line.strip() - - # Skip empty lines and M3U metadata lines - if not line or line.startswith("#"): - continue - - source_path = Path(line) - - # Check if source file exists - if not source_path.exists(): - log.warning( - f"Line {line_num}: Source file not found: {source_path}" - ) - skipped_count += 1 - continue - - # Create destination path, preserving the original filename - dest_path = collection_dir / source_path.name - - # Handle filename conflicts by adding a number suffix - counter = 1 - original_dest = dest_path - while dest_path.exists(): - stem = original_dest.stem - suffix = original_dest.suffix - dest_path = collection_dir / f"{stem}_{counter}{suffix}" - counter += 1 - - try: - shutil.copy2(source_path, dest_path) - log.info(f"Copied: {source_path.name} -> {dest_path.name}") - copied_count += 1 - except Exception as e: - log.error(f"Failed to copy {source_path}: {e}") - skipped_count += 1 - - except Exception as e: - ValueError(f"Error reading M3U file: {e}") - - log.info(f"Completed: {copied_count} files copied, {skipped_count} files skipped") - log.info(f"Files collected in: {collection_dir}") diff --git a/examples/community/plex_to_m3u.py b/examples/community/plex_to_m3u.py new file mode 100644 index 00000000..a5956d77 --- /dev/null +++ b/examples/community/plex_to_m3u.py @@ -0,0 +1,87 @@ +"""Export a single Plex playlist to M3U format, with optional path rewriting.""" + +from pathlib import Path +from typing import Annotated + +import typer + +from plistsync.core.rewrite import PathRewrite +from plistsync.logger import log +from plistsync.services.plex import PlexLibrarySectionCollection + + +def main( + playlist_name: Annotated[ + str, + typer.Argument(help="Name of the Plex playlist to export"), + ], + output_path: Annotated[ + Path, + typer.Argument(help="Output M3U file path (e.g. ./playlist.m3u)"), + ], + plex_section_name: Annotated[ + str, typer.Option(help="Name of the Plex section (e.g. 'Music')") + ] = "Music", + plex_path_base: Annotated[ + str | None, + typer.Option( + help="Base path used in Plex (e.g. '/media/music' or 'C:\\Users\\Music')" + ), + ] = None, + m3u_path_base: Annotated[ + str | None, + typer.Option( + help="Base path to use in output M3U (e.g. '/Volumes/music' or 'D:\\Music')" + ), + ] = None, + extm3u: Annotated[ + bool, + typer.Option( + help="Add comments according to EXTM3U Format (not supported by Traktor)" + ), + ] = False, +): + # Validate and build path rewrite + if sum([bool(plex_path_base), bool(m3u_path_base)]) == 1: + raise typer.BadParameter( + "Both 'plex_path_base' and 'm3u_path_base' must be provided together." + ) + + if plex_path_base is None or m3u_path_base is None: + path_rewrite = PathRewrite.from_str("", "") + else: + path_rewrite = PathRewrite.from_str(plex_path_base, m3u_path_base) + + # Load Plex library and playlist + plex_library = PlexLibrarySectionCollection(plex_section_name) + playlist = plex_library.get_playlist(name=playlist_name) + + if playlist is None: + raise ValueError(f"Plex playlist '{playlist_name}' not found.") + + # Build M3U content + m3u = "" + if extm3u: + m3u += "#EXTM3U\n" + m3u += "#PLAYLIST:" + playlist.name + "\n" + + num_m3u_tracks = 0 + for track in playlist.tracks: + if not track.path: + log.warning(f"Track '{track.title}' has no file path — skipping.") + continue + m3u += str(path_rewrite.apply(track.path)) + '\n' + num_m3u_tracks += 1 + + # Write M3U file + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(m3u, encoding="utf-8") + + log.info( + f"Exported '{playlist.name}' → {output_path} with {num_m3u_tracks} tracks" + ) + + +main.__doc__ = __doc__ # set help text from module docstring +if __name__ == "__main__": + typer.run(main) diff --git a/examples/community/plex_traktor_bidirectional.py b/examples/community/plex_traktor_bidirectional.py new file mode 100644 index 00000000..36d38c49 --- /dev/null +++ b/examples/community/plex_traktor_bidirectional.py @@ -0,0 +1,140 @@ +""" +Bidirectional sync between Plex and Traktor playlists. + +Only adds missing tracks, does not remove or reorder. +Matching takes place via file paths. + +The Path rewrite assumes your plex is remote and traktor is local. +""" + +from pathlib import Path +from typing import Annotated + +import typer + +from plistsync.core.rewrite import PathRewrite +from plistsync.logger import log +from plistsync.services.plex import PlexPlaylistCollection +from plistsync.services.plex.library import ( + PlexLibrarySectionCollection, +) +from plistsync.services.traktor import ( + NMLLibraryCollection, + NMLPlaylistCollection, + NMLPlaylistTrack, +) + + +def main( + traktor_nml_path: Annotated[ + Path, + typer.Argument( + help="Locations for your traktor `collection.nml file", + exists=True, + file_okay=True, + ), + ], + playlist_name: Annotated[ + str, typer.Argument(help="Name for the playlist to synchronize") + ], + plex_section_name: Annotated[ + str, + typer.Option(help="Name of the plex section that holds the playlist"), + ] = "Music", + plex_path_base: Annotated[ + str | None, + typer.Option( + help="File locations in plex, will be replaced with 'path_base_traktor'" + ), + ] = None, + traktor_path_base: Annotated[ + str | None, + typer.Option( + help="File locations in traktor, provide together with 'path_base_traktor'" + ), + ] = None, +): + # Check arguments, and create path rewrite + if sum([bool(plex_path_base), bool(traktor_path_base)]) == 1: + raise ValueError( + "Can only use 'plex_path_base' and 'traktor_path_base' together." + ) + elif plex_path_base is None or traktor_path_base is None: + path_rewrite = PathRewrite.from_str("/", "/") # dummy + else: + path_rewrite = PathRewrite.from_str(plex_path_base, traktor_path_base) + + # Get libraries + plex_library = PlexLibrarySectionCollection(plex_section_name) + traktor_library = NMLLibraryCollection(traktor_nml_path) + + # Get playlists + plex_playlist = plex_library.get_playlist(name=playlist_name) + traktor_playlist = traktor_library.get_playlist(name=playlist_name) + + # Or create them if missing + if plex_playlist is None: + if typer.prompt( + f"No plex playlist '{playlist_name}' found. Should we create it?", + type=bool, + default=True, + ): + plex_playlist = PlexPlaylistCollection(plex_library, playlist_name) + plex_playlist.remote_upsert() + else: + raise typer.Exit(-1) + + if traktor_playlist is None: + if typer.prompt( + f"No traktor playlist '{playlist_name}' found. Should we create it?", + type=bool, + default=True, + ): + traktor_playlist = NMLPlaylistCollection(traktor_library, playlist_name) + traktor_playlist.remote_upsert() + else: + raise typer.Exit(-1) + + # Compare Tracks via File path + plex_paths = set( + # Plex playlists do not support duplicates, so sets are fine. + path_rewrite.apply(track.path) # plex files might be on a different disk + for track in plex_playlist.tracks + if track.path # for historic reasons, plex tracks might not always have a path + ) + traktor_paths = set(track.path for track in traktor_playlist.tracks) + + # File paths are now local (like traktor) for both services + missing_in_traktor = plex_paths - traktor_paths + missing_in_plex = traktor_paths - plex_paths + + # Plex to traktor + log.info( + f"Adding {len(missing_in_traktor)} tracks from Plex to Traktor playlist..." + ) + with traktor_playlist.remote_edit(): + for p in missing_in_traktor: + # For traktor, PlaylistTracks are essentially only file paths, so + # we do not need a lookup. + traktor_playlist.tracks.append(NMLPlaylistTrack.from_path(p)) + + # Commit changes to Traktor NML file + traktor_library.write() + + # Traktor to plex + log.info(f"Adding {len(missing_in_plex)} tracks from Traktor to Plex playlist...") + with plex_playlist.remote_edit(): + for p in missing_in_plex: + p_for_plex = path_rewrite.invert.apply(p) + plex_track = plex_library.find_by_local_ids({"file_path": p_for_plex}) + if plex_track is None: + log.warning(f"Could not find track in plex: {str(p_for_plex)}") + else: + plex_playlist.tracks.append(plex_track) + + log.info("Sync complete.") + + +main.__doc__ = __doc__ # use module docstring as help +if __name__ == "__main__": + typer.run(main) diff --git a/examples/exploration.ipynb b/examples/exploration.ipynb deleted file mode 100644 index 4e698761..00000000 --- a/examples/exploration.ipynb +++ /dev/null @@ -1,196 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": {}, - "outputs": [], - "source": [ - "%reload_ext autoreload\n", - "%autoreload 2\n", - "\n", - "import os\n", - "\n", - "os.environ[\"PSYNC_CONFIG_DIR\"] = \"../config/\"\n", - "\n", - "import plistsync\n", - "import plistsync.services.plex.api as papi\n", - "\n", - "plistsync.services.plex.api.log.setLevel(\"DEBUG\")\n", - "\n", - "plistsync.services.plex.api.resolve_section_id(\n", - " \"Music\"\n", - ") # Example usage to test the function\n", - "\n", - "\n", - "# takes some 5 seconds\n", - "res = plistsync.services.plex.api.fetch_tracks_by_path(\n", - " \"/media/music/clean/Etherwood, Grace Barton/Where The River Meets The Sea (feat. Grace Barton)/01 Where The River Meets The Sea (feat. Grace Barton) [1067kbps].flac\",\n", - " section_id=5,\n", - ")\n", - "res[0].get(\"title\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "playlist = plistsync.services.plex.api.fetch_playlist(\"109486\")\n", - "playlist" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "res = plistsync.services.plex.api.fetch_tracks_by_id(106387)\n", - "res[0].get(\"title\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "all_tracks = list(\n", - " plistsync.services.plex.api.fetch_tracks(section_id=5, page_size=5000)\n", - ")\n", - "\n", - "[t.get(\"title\") for t in all_tracks[0:2]]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "res = plistsync.services.plex.api.fetch_tracks_by_id(106387, cache=all_tracks)\n", - "res[0].get(\"title\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [ - "res = plistsync.services.plex.api.fetch_tracks_by_path(\n", - " \"/media/music/clean/Etherwood, Grace Barton/Where The River Meets The Sea (feat. Grace Barton)/01 Where The River Meets The Sea (feat. Grace Barton) [1067kbps].flac\",\n", - " section_id=5,\n", - " cache=all_tracks,\n", - ")\n", - "res[0].get(\"title\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "plistsync.services.plex.api.fetch_playlist_items(\"109486\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "# papi.resolve_playlist_id(\"_plistsync\")\n", - "papi.fetch_playlist(109614)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# get the root path of the section\n", - "plistsync.services.plex.api.request(\"/library/sections\")\n", - "plistsync.services.plex.api.fetch_section_root_path(\"5\")\n", - "plistsync.services.plex.api.request(\"/identity\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "res = papi.insert_track_into_playlist_by_id(\n", - " track_id=109481,\n", - " # track_id=106387,\n", - " playlist_id=109614,\n", - ")\n", - "res" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "pl = plistsync.services.plex.collection.PlexPlaylistCollection(\"_plistsync\")\n", - "\n", - "pl.insert_by_id(109481)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "plistsync.services.plex.track.log.setLevel(\"INFO\")\n", - "pl.library_collection = plistsync.services.plex.collection.PlexLibrarySectionCollection(\n", - " \"Music\"\n", - ")\n", - "pl.insert_by_path(\n", - " \"/media/music/clean/Industrial Sound/Melodic Techno Bangers/01 Melodic Techno, Pt1 [827kbps].flac\"\n", - ")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "plistsync", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/plex_playlists_to_m3u.py b/examples/plex_playlists_to_m3u.py deleted file mode 100644 index 3255475d..00000000 --- a/examples/plex_playlists_to_m3u.py +++ /dev/null @@ -1,73 +0,0 @@ -import re -from pathlib import Path - -from plistsync.logger import log -from plistsync.services.plex import PlexLibrarySectionCollection -from plistsync.services.plex.playlist import PlexPlaylistCollection - -# ---------------------------------- Options --------------------------------- # -playlists = [ - "playlist1", - "playlist2", - "playlist3", -] -plex_section_name = "Music" -output_dir = Path.cwd().resolve() - -path_rewrite = "/media/music/clean:/Volumes/music/clean" - - -def main( - playlists: list[str], - outpath: Path, - path_rewrite: str | None = None, -): - # Load Plex library and playlist - plex_library = PlexLibrarySectionCollection( - plex_section_name, - ) - for playlist_id_or_name in playlists: - log.info(f"\nProcessing playlist: {playlist_id_or_name}") - pl = PlexPlaylistCollection( - plex_library, - playlist_id_or_name, - ) - - if path_rewrite: - old, new = path_rewrite.split(":") - else: - old = new = "" - - m3u = "" - for track in pl.tracks: - if not str(track.path).startswith(old): - raise Warning( - f"Track {track.path} does not start with" - f" the specified rewrite ({old})." - ) - m3u += ( - str(track.path).replace( - old, - new, - ) - + "\n" - ) - - # remove non-safe characters that cannot go into filenames - safe_pl_name = re.sub(r"[^\w\s-]", "", pl.name).strip().replace(" ", "_") - outpath = output_dir / f"{safe_pl_name}.m3u" - - with open(outpath, "w", encoding="utf-8") as f: - # Note: Traktor Pro 4 does not understand the headers - # and imports them as tracks xD - # f.write("# EXTM3U\n") - # f.write("# PLAYLIST: " + pl.name + "\n") - f.write(m3u) - - -if __name__ == "__main__": - main( - playlists=playlists, - outpath=output_dir, - path_rewrite=path_rewrite, - ) diff --git a/examples/plex_playlists_to_traktor.py b/examples/plex_playlists_to_traktor.py deleted file mode 100644 index d45f0dc5..00000000 --- a/examples/plex_playlists_to_traktor.py +++ /dev/null @@ -1,84 +0,0 @@ -import shutil -from datetime import datetime -from pathlib import Path - -from plistsync.core.rewrite import PathRewrite -from plistsync.logger import log -from plistsync.services.plex.library import PlexLibrarySectionCollection -from plistsync.services.traktor import ( - NMLLibraryCollection, - NMLPlaylistCollection, - NMLPlaylistTrack, -) - -# ---------------------------------- Options --------------------------------- # -playlists = [ - "playlist1", - "playlist2", - "playlist3", -] -plex_section_name = "Music" -path_rewrite = PathRewrite.from_str( - "/media/music/clean", - "/Volumes/music/clean", -) -nml_path = Path("./traktor_collection.nml") - - -def main( - playlists: list[str], - nml_path: Path, - path_rewrite: PathRewrite | None = None, -): - plex_library = PlexLibrarySectionCollection( - plex_section_name, - ) - traktor_library = NMLLibraryCollection(nml_path) - # make a backup of the nml file just in case - nml_backup = nml_path.with_suffix( - f".{datetime.now().strftime('%Y%m%d-%H%M%S')}.bak" - ) - shutil.copyfile(nml_path, nml_backup) - - for pl_name in playlists: - log.info(f"\nProcessing playlist: {pl_name}") - pl_plex = plex_library.get_playlist(name=pl_name) - assert pl_plex is not None, "Playlist not found" - - # Get or create playlist in traktor - try: - traktor_playlist = traktor_library.get_playlist(name=pl_plex.name) - except ValueError: - traktor_playlist = NMLPlaylistCollection( - traktor_library, - pl_plex.name, - ) - traktor_playlist.remote_upsert() - - with traktor_playlist.remote_edit(): - for track in pl_plex.tracks: - try: - p = track.path - except Exception: - log.error(f"Track {track} does not have a valid path.") - continue - - if not p: - continue - - if path_rewrite: - p = path_rewrite.apply(p) - - if p: - traktor_playlist.tracks.append( - NMLPlaylistTrack.from_path(p), - ) - traktor_library.write() - - -if __name__ == "__main__": - main( - playlists=["Set Liquide"], - nml_path=nml_path, - path_rewrite=path_rewrite, - ) diff --git a/examples/plex_traktor_bidirectional.py b/examples/plex_traktor_bidirectional.py deleted file mode 100644 index d0b3aaac..00000000 --- a/examples/plex_traktor_bidirectional.py +++ /dev/null @@ -1,98 +0,0 @@ -# ------------------------------------------------------------------------------ # -# @Author: F. Paul Spitzner -# @Created: 2025-08-17 09:48:49 -# @Last Modified: 2026-02-24 14:39:26 -# ------------------------------------------------- - -""" -Bidirectional sync between Plex and Traktor playlists. - -Only adds missing tracks, does not remove or reorder. -Matching takes place via file paths. - -TODO: -- Rewrite -- Commiting: I like the Traktor way of doing things first, and then committing. -But in Plex, inserts are currently one http request per track. -Eventually we should unify this, and check if there is an -`insert_multiple` endpoint. -(there should be, via web-frontend you can do it). -- Traktor: check files and volumes found in lib - Alternativ: write track entries with file path checks. -""" - -from pathlib import Path - -from plistsync.core.rewrite import PathRewrite -from plistsync.logger import log -from plistsync.services.plex.library import ( - PlexLibrarySectionCollection, -) -from plistsync.services.traktor import ( - NMLLibraryCollection, - NMLPlaylistCollection, - NMLPlaylistTrack, -) - -# ---------------------------------- Options --------------------------------- # - -playlist_name = "_plistsync" -traktor_nml_path = Path("/Users/paul/Music/Traktor/collection.nml") -plex_section_name = "Music" -path_rewrite = PathRewrite(Path("/media/music/clean"), Path("/Traktor/clean")) - - -def main(): - # Load Plex library and playlist - plex_library = PlexLibrarySectionCollection( - plex_section_name, - ) - plex_playlist = plex_library.get_playlist(name=playlist_name) - assert plex_playlist is not None, "Playlist not found" - - # Load Traktor collection - traktor_collection = NMLLibraryCollection(traktor_nml_path) - # Get or create playlist - try: - traktor_playlist = traktor_collection.get_playlist(name=playlist_name) - except ValueError: - traktor_playlist = NMLPlaylistCollection( - traktor_collection, - playlist_name, - ) - traktor_playlist.remote_upsert() - - # --- Add missing tracks from Plex to Traktor --- # - # Rewrite paths from plex to match traktor paths - # TODO: Rethink as set approach is limited - plex_paths = set( - path_rewrite.apply(track.path) for track in plex_playlist.tracks if track.path - ) - traktor_paths = set(track.path for track in traktor_playlist.tracks) - - missing_in_traktor = plex_paths - traktor_paths - log.info( - f"Adding {len(missing_in_traktor)} tracks from Plex to Traktor playlist..." - ) - with traktor_playlist.remote_edit(): - for path in missing_in_traktor: - traktor_playlist.tracks.append(NMLPlaylistTrack.from_path(path)) - - # --- Add missing tracks from Traktor to Plex --- # - missing_in_plex = traktor_paths - plex_paths - log.info(f"Adding {len(missing_in_plex)} tracks from Traktor to Plex playlist...") - with plex_playlist.remote_edit(): - for path in missing_in_plex: - try: - # FIXME! - plex_playlist.tracks.append(path_rewrite.invert.apply(path)) # type:ignore - except Exception as e: - log.warning(f"Could not add {path} to Plex playlist: {e}") - - # Commit changes to Traktor NML file - traktor_collection.write() - log.info("Sync complete.") - - -if __name__ == "__main__": - main() diff --git a/examples/readme.md b/examples/readme.md new file mode 100644 index 00000000..b9573509 --- /dev/null +++ b/examples/readme.md @@ -0,0 +1,27 @@ +# Examples + +Practical, ready-to-run workflows for using `plistsync` to sync and maintain playlists across services. + +This section includes both official examples and community contributions. If you’ve written a script, config pattern, or workflow that could help others get started (or save them time), feel free to open a PR. + +## Official examples + +All official examples live in the [`examples` folder](https://github.com/metasauce/plistsync/tree/main/examples) at the root of the repository and are provided as either Jupyter notebooks or python scripts. + +```{toctree} +:maxdepth: 1 + +sync_spotify_tidal.ipynb +transcode_playlist.ipynb +``` + +## Community showcase + +A curated list of community workflows and scripts built around `plistsync` can be found in [`examples/community`](https://github.com/metasauce/plistsync/tree/main/examples/community) + +Current examples: +- Sync Traktor and Plex (only inserting, both ways, no deletion) [link](https://github.com/metasauce/plistsync/tree/main/examples/community/plex_traktor_bidirectional.py) + +```{note} Want to be featured? +Open a PR adding your project/script here with a short description and a link. +``` diff --git a/examples/sync_spotify_tidal.ipynb b/examples/sync_spotify_tidal.ipynb new file mode 100644 index 00000000..1acb1f2a --- /dev/null +++ b/examples/sync_spotify_tidal.ipynb @@ -0,0 +1,272 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": { + "tags": [ + "hide-cell" + ] + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# Update you config directory as needed\n", + "os.environ[\"PSYNC_CONFIG_DIR\"] = \"../config\"" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "# Synchronize a playlist between Spotify and Tidal\n", + "\n", + "This example keeps **one playlist mirrored across Spotify and Tidal**. You can start from:\n", + "- an existing playlist on **either** service, or\n", + "- two empty playlists.\n", + "\n", + "Tracks are matched using their **ISRC** (International Standard Recording Code), which makes cross‑service matching reliable.\n", + "\n", + "\n", + "```{note}\n", + "This notebook is intentionally step-by-step and a bit more explicit than strictly necessary, to make the workflow easy to follow.\n", + "```\n", + "\n", + "## Prerequisites\n", + "\n", + "Before running the notebook, make sure you have spotify and tidal authenticated.\n", + "\n", + "```bash\n", + "plistsync spotify auth \n", + "plistsync tidal auth\n", + "```\n", + "\n", + "## The initial playlist(s)\n", + "\n", + "We start by fetching both the existing spotify playlist and tidal playlist. We can also create a new one if needed. If you think the name of the playlist will change you should consider using ids for the retrieval instead.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "name = \"Fresh Songs\"\n", + "description = \"New songs from the last 2 weeks.\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "from plistsync.services.spotify import (\n", + " SpotifyLibraryCollection,\n", + " SpotifyPlaylistCollection,\n", + ")\n", + "\n", + "spotify_library = SpotifyLibraryCollection()\n", + "\n", + "# Get playlist by name\n", + "_spotify_playlist = spotify_library.get_playlist(name=name)\n", + "\n", + "# Create if it does not exist\n", + "if _spotify_playlist is None:\n", + " spotify_playlist = SpotifyPlaylistCollection(\n", + " library=spotify_library,\n", + " name=name,\n", + " description=description,\n", + " )\n", + "else:\n", + " spotify_playlist = _spotify_playlist\n", + "\n", + "print(\n", + " f\"Playlist (spotify): {spotify_playlist.name} \"\n", + " f\"({len(spotify_playlist.tracks)} tracks)\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Tidal is intentionally handled the same as Spotify: same interface, same\n", + "normalization, so the get-or-create playlist logic is identical." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "from plistsync.services.tidal import (\n", + " TidalLibraryCollection,\n", + " TidalPlaylistCollection,\n", + ")\n", + "\n", + "tidal_library = TidalLibraryCollection()\n", + "\n", + "# Get playlist by name\n", + "_tidal_playlist = tidal_library.get_playlist(name=name)\n", + "\n", + "# Create if it does not exist\n", + "if _tidal_playlist is None:\n", + " tidal_playlist = TidalPlaylistCollection(\n", + " library=tidal_library,\n", + " name=name,\n", + " description=description,\n", + " )\n", + "else:\n", + " tidal_playlist = _tidal_playlist\n", + "\n", + "print(f\"Playlist (tidal): {tidal_playlist.name} ({len(tidal_playlist.tracks)} tracks)\")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "```{note}\n", + "If the playlist doesn’t exist on the service yet, we only create a local playlist object here. The actual remote playlist on Spotify/Tidal is created later (at the end of the notebook) when we persist changes.\n", + "``` " + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## The playlist differences\n", + "\n", + "Even if playlists start out identical, they can drift over time. Tracks may be added or removed manually, the order can change, and some songs may exist on one service but be unavailable (or matched differently) on another. In this section we compute and than apply the updates.\n", + "\n", + "We recommend choosing one playlist as the source of truth. If both playlists are edited manually, additions/removals (and especially ordering) can conflict in ways that can’t be resolved automatically without a baseline to sync against. In a later section, we extend this to bidirectional sync using a local reference track list.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "from plistsync.core.diff import list_diff\n", + "from plistsync.core.track import Track\n", + "\n", + "\n", + "def hash_func(x: Track):\n", + " \"\"\"Return a stable identity key for diffing tracks across services.\n", + "\n", + " `list_diff` groups and compares items using this key. If two tracks produce\n", + " the same key, they are treated as the same logical song (even if other\n", + " metadata differs).\n", + "\n", + " We use the ISRC because it is typically stable across Spotify and Tidal and\n", + " more reliable than string matching on title/artist. Tracks without an ISRC\n", + " return `None` and may be treated as unmatched.\n", + " \"\"\"\n", + " return x.isrc\n", + "\n", + "\n", + "ops = list_diff(\n", + " old=tidal_playlist.tracks, # current state (to be updated)\n", + " new=spotify_playlist.tracks, # desired state (source of truth)\n", + " hash_func=hash_func,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "The {py:func}`list_diff ` function computes the minimal set of operations needed to transform an **old** list into a **new** list. In this notebook we treat Spotify as the source of truth, so we diff the current Tidal playlist (**old**) against the Spotify playlist (**new**) and then apply the resulting operations to bring Tidal in sync." + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "Next we apply the changes and upsert the playlist(s). We use the `remote_edit` context manager here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "from plistsync.core.diff import DeleteOp, InsertOp, MoveOp\n", + "from plistsync.services.tidal.track import TidalPlaylistTrack, TidalTrack\n", + "\n", + "# After closing the `remote_edit` context manager\n", + "# the changes are minified, batched and applied\n", + "# to the remote version of the playlist\n", + "with tidal_playlist.remote_edit():\n", + " # Name or description update\n", + " tidal_playlist.name = spotify_playlist.name\n", + " tidal_playlist.description = spotify_playlist.description\n", + "\n", + " # Track changes\n", + " for op in ops:\n", + " if isinstance(op, InsertOp):\n", + " # find the new track on tidal\n", + " tidal_track: TidalTrack | None = tidal_library.match(op.item).best_match\n", + " if tidal_track is not None:\n", + " tidal_playlist.tracks.append(\n", + " TidalPlaylistTrack(\n", + " tidal_track,\n", + " ),\n", + " )\n", + " elif isinstance(op, MoveOp):\n", + " tidal_track = tidal_playlist.tracks.pop(op.old_idx)\n", + " tidal_playlist.tracks.insert(op.new_idx, tidal_track)\n", + " elif isinstance(op, DeleteOp):\n", + " tidal_playlist.tracks.pop(op.idx)" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "After running this example, we have successfully synchronized a **Tidal playlist** to match a **Spotify playlist**. \n", + "This synchronization is **one-directional**: Spotify is treated as the source of truth, and changes are applied only to Tidal (it does not sync updates back to Spotify)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/traktor_to_mp3.ipynb b/examples/traktor_to_mp3.ipynb deleted file mode 100644 index 8c55ab77..00000000 --- a/examples/traktor_to_mp3.ipynb +++ /dev/null @@ -1,222 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "\n", - "from plistsync.core.rewrite import PathRewrite\n", - "from plistsync.services import traktor" - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "Edit the config below" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "collection_path = \"/Users/paul/Music/Traktor/collection.nml\"\n", - "\n", - "# Im using traktor on windows on an external drive D:/SYNC\n", - "# and running the script on my linux machine\n", - "rewrite = PathRewrite.from_str(old=\"/D:/SYNC\", new=\"/mnt/media/music\")\n", - "\n", - "outdir = \"./out\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "collection = traktor.NMLCollection(collection_path)" - ] - }, - { - "cell_type": "markdown", - "id": "4", - "metadata": {}, - "source": [ - "List all playlist in collection. At the moment we do not have a way to show the collection structure." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [ - "for pl in collection.playlists():\n", - " print(pl.name)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# Select a playlist\n", - "plist_name = \"synthetic roots\"\n", - "pl = collection.playlist(plist_name)" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "I want to create a directory for the output converted music." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "out_pl_dir = Path(outdir) / plist_name\n", - "out_pl_dir.mkdir(parents=True, exist_ok=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "# Create local tracks from the tracks to write to new locations\n", - "\n", - "from plistsync.services.local import LocalTrack\n", - "from plistsync.services.traktor import NMLPlaylistTrack\n", - "\n", - "\n", - "def to_local_track(track: NMLPlaylistTrack) -> LocalTrack:\n", - " \"\"\"Convert a NMLTrack to a local track.\"\"\"\n", - " local_path = rewrite.apply(track.path)\n", - " return LocalTrack(\n", - " path=local_path,\n", - " )\n", - "\n", - "\n", - "local_tracks = [to_local_track(t) for t in pl]" - ] - }, - { - "cell_type": "markdown", - "id": "10", - "metadata": {}, - "source": [ - "Convert all files from flac to mp3 as cdjs normally cant play flac\n", - "`$ pip install python-ffmpeg`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "from ffmpeg import FFmpeg\n", - "\n", - "\n", - "# Convert to mp3 if not already mp3 using ffmpeg\n", - "def _transcode_to_mp3(input_path: Path, output_path: Path) -> None:\n", - " if output_path.exists():\n", - " return\n", - "\n", - " pipe = (\n", - " FFmpeg()\n", - " .option(\"ab\", \"320k\") # Set audio bitrate\n", - " .option(\"map_metadata\", \"0\")\n", - " .option(\"id3v2_version\", \"3\")\n", - " .input(str(input_path))\n", - " .output(str(output_path))\n", - " )\n", - " pipe.execute()\n", - "\n", - "\n", - "def to_mp3_local_track(track: LocalTrack, out_dir: Path) -> LocalTrack:\n", - " \"\"\"Convert a local track to a mp3 local track.\"\"\"\n", - " out_path = out_dir / track.path.name.replace(\".flac\", \".mp3\")\n", - " _transcode_to_mp3(track.path, out_path)\n", - " return LocalTrack(\n", - " path=out_path,\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "mp3_tracks = [to_mp3_local_track(t, out_pl_dir) for t in local_tracks]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "# Create simple m3u with all files\n", - "m3u_path = out_pl_dir / f\"{plist_name}.m3u\"\n", - "with m3u_path.open(\"w\") as f:\n", - " for track in mp3_tracks:\n", - " f.write(f\"./{track.path.relative_to(out_pl_dir)}\\n\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "14", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "plistsync", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/transcode_playlist.ipynb b/examples/transcode_playlist.ipynb new file mode 100644 index 00000000..1a76a54b --- /dev/null +++ b/examples/transcode_playlist.ipynb @@ -0,0 +1,336 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": { + "tags": [ + "hide-cell" + ] + }, + "outputs": [], + "source": [ + "import os\n", + "import shutil\n", + "\n", + "# Update you config directory as needed\n", + "os.environ[\"PSYNC_CONFIG_DIR\"] = \"../config\"\n", + "\n", + "if shutil.which(\"ffmpeg\") is None:\n", + " raise RuntimeError(\n", + " \"ffmpeg not found on PATH. Install ffmpeg and restart the kernel.\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "# Transcode a Traktor playlist to MP3 files\n", + "\n", + "This notebook exports a single Traktor playlist from a `collection.nml` and creates:\n", + "\n", + "- A output directory containing all audio files transcode into MP3 files\n", + "- An `.m3u` playlist that references those files\n", + "\n", + "## Typical use case\n", + "\n", + "- Your Traktor collection was built on **Windows** (e.g. files live under `D:/SYNC/...`)\n", + "- You are running this notebook on **Linux**, where the same drive is mounted somewhere like `/mnt/media/music`\n", + "- Multiple music formats need to be converted to mp3 for other applications\n", + "\n", + "## Prerequisites\n", + "\n", + "Make sure you have `ffmpeg` installed and available on your `PATH` (`ffmpeg -version` works)." + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "First we obtain our traktor `.nml` library collection which contains the playlists saved inside the traktor application." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "from plistsync.services.traktor import NMLLibraryCollection\n", + "\n", + "# Path to you traktor collection\n", + "collection_path = \"/Users/paul/Music/Traktor/collection.nml\"\n", + "collection = NMLLibraryCollection(collection_path)" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "We can quickly print all available playlists to get an overview of our library." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "for pl in collection.playlists:\n", + " print(pl.name)" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "Next we select one playlist out of all playlist in the collection. For this change the `name` to the playlist you want to choose." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "playlist = collection.get_playlist_or_raise(name=\"pl_name\")" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "Let's create a folder with the same name as the playlist to store the converted tracks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "out_dir = Path(f\"./{playlist.name}\")\n", + "out_dir.mkdir(parents=True, exist_ok=True)" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "## Converting `NMLTrack`s to `LocalTrack`s\n", + "\n", + "`NMLTrack`/`NMLPlaylistTrack` objects are parsed from Traktor’s `collection.nml` and describe playlist tracks as Traktor stores them. `LocalTrack` represents the same track as a local filesystem `Path`, suitable for copying or transcoding." + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "In case you are running this on a machine with different\n", + "mount points or paths to the one defined in the traktor `.nml` library\n", + "you will need to apply a path rewrite the paths.\n", + "\n", + "We expose the nifty {py:class}`PathRewrite ` class to help you with this." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "from plistsync.core.rewrite import PathRewrite\n", + "\n", + "# On windows we mount an external drive with music D:/Music\n", + "# While running the script on a linux machine the the music\n", + "# is now located in /mnt/media/music\n", + "rewrite = PathRewrite.from_str(old=\"/D:/SYNC\", new=\"/mnt/media/music\")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "Converting to `LocalTrack`s lets us verify the files exist and are readable/accessible before processing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "from plistsync.services.local import LocalTrack\n", + "from plistsync.services.traktor import NMLPlaylistTrack, NMLTrack\n", + "\n", + "\n", + "def to_local_track(track: NMLPlaylistTrack | NMLTrack) -> LocalTrack:\n", + " \"\"\"Convert a NMLTrack to a local track.\"\"\"\n", + " local_path = rewrite.apply(track.path)\n", + " return LocalTrack(\n", + " path=local_path,\n", + " )\n", + "\n", + "\n", + "local_tracks = [to_local_track(t) for t in pl.tracks]" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "Not all local tracks are already MP3, and sometimes you need a specific format for playback. We use `ffmpeg` to transcode tracks when needed before writing them to the output directory." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "import subprocess\n", + "\n", + "\n", + "def _transcode_to_mp3(input_path: Path, output_path: Path) -> None:\n", + " \"\"\"\n", + " Transcode an audio file to MP3 using the system `ffmpeg` binary.\n", + "\n", + " Notes\n", + " -----\n", + " Requires `ffmpeg` to be installed and available on PATH.\n", + " Metadata is copied from the input and ID3v2.3 tags are written for\n", + " broad compatibility.\n", + " \"\"\"\n", + " output_path.parent.mkdir(parents=True, exist_ok=True)\n", + " if output_path.exists():\n", + " return\n", + "\n", + " # fmt: off\n", + " cmd = [\n", + " \"ffmpeg\", \"-hide_banner\", \"-loglevel\", \"error\", \"-n\",\n", + " \"-i\", str(input_path),\n", + " \"-map_metadata\", \"0\", # Copy metadata\n", + " \"-id3v2_version\", \"3\", # ID3v2.3\n", + " \"-vn\", # Drop video stream\n", + " \"-codec:a\", \"libmp3lame\", # encoder\n", + " \"-b:a\", \"320k\", # bitrate\n", + " str(output_path),\n", + " ]\n", + " subprocess.run(cmd, check=True)\n", + " # fmt: on\n", + "\n", + "\n", + "def to_mp3_local_track(track: LocalTrack, out_dir: Path) -> LocalTrack:\n", + " \"\"\"\n", + " Ensure `track` is available as an MP3 inside `out_dir`.\n", + "\n", + " - If the input is already an MP3, it is copied (no re-encoding).\n", + " - Otherwise, it is transcoded to MP3 via `_transcode_to_mp3()`.\n", + "\n", + " Returns a `LocalTrack` pointing at the MP3 in `out_dir`.\n", + " \"\"\"\n", + " # Copy if already mp3\n", + " if track.path.suffix.lower() == \".mp3\":\n", + " out_path = out_dir / track.path.name\n", + " if not out_path.exists():\n", + " shutil.copy2(track.path, out_path)\n", + " return LocalTrack(path=out_path)\n", + "\n", + " # Transcode if not mp3\n", + " out_path = out_dir / track.path.with_suffix(\".mp3\").name\n", + " _transcode_to_mp3(track.path, out_path)\n", + " return LocalTrack(path=out_path)" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "We now copy tracks into the output directory, transcoding to MP3 only when needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "mp3_tracks = [to_mp3_local_track(t, out_dir) for t in local_tracks]" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "## Creating the M3U playlist\n", + "\n", + "Lastly, we write an `.m3u` playlist file into the same output folder. It is a simple text format with one track path per line." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "m3u_path = out_dir / f\"{playlist.name}.m3u\"\n", + "with m3u_path.open(\"w\") as f:\n", + " for track in mp3_tracks:\n", + " f.write(f\"./{track.path.relative_to(out_dir)}\\n\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/top100dnb.ipynb b/notebooks/top100dnb.ipynb deleted file mode 100644 index 12e75198..00000000 --- a/notebooks/top100dnb.ipynb +++ /dev/null @@ -1,203 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "# Update you config directory as needed\n", - "os.environ[\"PSYNC_CONFIG_DIR\"] = \"../config\"\n", - "\n", - "import logging\n", - "\n", - "logging.basicConfig(level=\"DEBUG\")" - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "# Sync example Tidal and Spotify\n", - "\n", - "In this example we will show how to sync a playlist from Spotify to Tidal. This is a pretty easy use case, because tracks on both services can be identified by their [ISRC](https://en.wikipedia.org/wiki/International_Standard_Recording_Code).\n", - "\n", - "To execute this example you will need to configure both the spotify and tidal services.\n", - "\n", - "\n", - "## The initial playlist\n", - "\n", - "We start with a spotify playlist here and create a tidal playlist with the same name if it does not exist yet. You can\n", - "also start with two blank playlists.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "from plistsync.services.spotify import (\n", - " SpotifyLibraryCollection,\n", - ")\n", - "\n", - "spotify_library = SpotifyLibraryCollection()\n", - "\n", - "# Spotify id of the playlist to sync (Drum & Bass Top 100)\n", - "# By name\n", - "if spotify_playlist := spotify_library.get_playlist(\n", - " url=\"https://open.spotify.com/playlist/0Zarq4BVkFkZOWkmqsfrjA\"\n", - "):\n", - " print(\n", - " f\"Found playlist: {spotify_playlist.name} \"\n", - " f\"({len(spotify_playlist.tracks)} tracks)\"\n", - " )\n", - "else:\n", - " print(\"Playlist not found.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "spotify_playlist.name" - ] - }, - { - "cell_type": "markdown", - "id": "4", - "metadata": {}, - "source": [ - "Next up we create a corresponding playlist on Tidal (or get it if it already exists):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [ - "from plistsync.services.tidal import TidalLibraryCollection, TidalPlaylistCollection\n", - "\n", - "tidal_library = TidalLibraryCollection()\n", - "\n", - "if tidal_library.has_playlist(spotify_playlist.name):\n", - " # Playlist exists, we can use it\n", - " _tidal_playlist = tidal_library.get_playlist(name=spotify_playlist.name)\n", - " assert _tidal_playlist is not None\n", - " tidal_playlist = _tidal_playlist\n", - " print(\n", - " f\"Using existing Tidal playlist '{tidal_playlist.id}' \"\n", - " f\"with {len(tidal_playlist)} tracks\"\n", - " )\n", - "else:\n", - " # Create the playlist\n", - " resource, lookup = tidal_library.api.playlist.create(\n", - " name=spotify_playlist.name,\n", - " description=spotify_playlist.description or \"\",\n", - " )\n", - " tidal_playlist = TidalPlaylistCollection.from_response_data(\n", - " tidal_library, resource, lookup\n", - " )\n", - " print(f\"Created Tidal playlist '{tidal_playlist.id}'\")" - ] - }, - { - "cell_type": "markdown", - "id": "6", - "metadata": {}, - "source": [ - "We now have both playlists and can start to sync them. We will add tracks that are in the Spotify playlist but not in the Tidal one.\n", - "\n", - "The following might not be super efficient, but it works for demonstration purposes. Maybe we can optimize it later?" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "from plistsync.services.spotify import SpotifyPlaylistTrack\n", - "from plistsync.services.tidal import TidalPlaylistTrack\n", - "\n", - "# Find tracks in Spotify playlist that are not in Tidal playlist\n", - "missing_tracks: list[SpotifyPlaylistTrack] = []\n", - "\n", - "tidal_playlist._refetch_tracks()\n", - "print([t for t in tidal_playlist.tracks])\n", - "\n", - "for track_s in spotify_playlist.tracks:\n", - " # Try to match by ISRC only (cutoff 1.0)\n", - " # TODO: implement playlist.match logic (global and local lookup, so match works)\n", - " # matches = tidal_playlist.match(track_s, cutoff=0.8)\n", - " # if len(matches.found) == 0:\n", - " # missing_tracks.append(track_s)\n", - "\n", - " if track_s.isrc is not None and track_s.isrc not in [\n", - " t.isrc for t in tidal_playlist.tracks\n", - " ]:\n", - " missing_tracks.append(track_s)\n", - "\n", - "print(\n", - " f\"Found {len(missing_tracks)} missing tracks in Tidal playlist '{tidal_playlist.name}'\"\n", - ")\n", - "\n", - "\n", - "tidal_tracks = tidal_library.find_many_by_global_ids(\n", - " map(lambda t: t.global_ids, missing_tracks)\n", - ")\n", - "tidal_tracks = list(filter(None, tidal_tracks))\n", - "print(f\"Found {len(tidal_tracks)} out of {len(missing_tracks)} tracks on Tidal\")\n", - "\n", - "# service-level tracks have less information than playlist tracks, so we have to convert\n", - "tidal_tracks = [TidalPlaylistTrack(t) for t in tidal_tracks]\n", - "\n", - "with tidal_playlist.remote_edit():\n", - " # as of now, inserts are one api call each. (this will take a while)\n", - " tidal_playlist.tracks.extend(tidal_tracks)\n", - "\n", - "print(f\"Tidal playlist '{tidal_playlist.name}' now has {len(tidal_playlist)} tracks\")" - ] - }, - { - "cell_type": "markdown", - "id": "8", - "metadata": {}, - "source": [ - "One directional sync is now done. More features coming soon!" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "plistsync", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/plistsync/core/diff.py b/plistsync/core/diff.py index 28ef4156..4dce113a 100644 --- a/plistsync/core/diff.py +++ b/plistsync/core/diff.py @@ -114,6 +114,9 @@ def _apply_op_to_list(self, op: Ops[T], working_list: list[T]) -> bool: def __getitem__(self, index: int) -> Op[T]: return self.ops[index] + def __iter__(self): + yield from self.iter() + def batch_consecutive( ops: Iterable[Step[T]], diff --git a/plistsync/services/plex/api.py b/plistsync/services/plex/api.py index 2ca371af..8af03f42 100644 --- a/plistsync/services/plex/api.py +++ b/plistsync/services/plex/api.py @@ -548,7 +548,7 @@ def fetch_tracks( if total_size != 0: log.debug( f"Fetched {num_fetched} of {total_size} tracks" - " from section {section_id}." + f" from section {section_id}." ) yield from tracks diff --git a/plistsync/services/plex/library.py b/plistsync/services/plex/library.py index e4a1a41f..1e203e6a 100644 --- a/plistsync/services/plex/library.py +++ b/plistsync/services/plex/library.py @@ -1,7 +1,7 @@ -from collections.abc import Generator, Iterable, Sequence +from collections.abc import Iterable, Sequence from functools import cached_property from pathlib import Path -from typing import Any, overload +from typing import overload from requests import HTTPError @@ -17,7 +17,10 @@ class PlexLibrarySectionCollection( - LibraryCollection[PlexTrack], LocalLookup, GlobalLookup, TrackStream[PlexTrack] + LibraryCollection[PlexTrack, PlexPlaylistCollection], + TrackStream[PlexTrack], + LocalLookup[PlexTrack], + GlobalLookup[PlexTrack], ): """A collection of all tracks in a Plex library section. @@ -141,7 +144,7 @@ def locations(self) -> list[Path]: _fetched: bool = False @property - def tracks(self) -> Generator[PlexTrack, Any, None]: + def tracks(self) -> Iterable[PlexTrack]: """Iterate over the tracks in the collection.""" if self._tracks is None or not self._fetched: diff --git a/plistsync/services/spotify/library.py b/plistsync/services/spotify/library.py index 2f50d40c..09644766 100644 --- a/plistsync/services/spotify/library.py +++ b/plistsync/services/spotify/library.py @@ -15,7 +15,10 @@ from .track import SpotifyTrack -class SpotifyLibraryCollection(LibraryCollection[SpotifyTrack], GlobalLookup): +class SpotifyLibraryCollection( + LibraryCollection[SpotifyTrack, SpotifyPlaylistCollection], + GlobalLookup[SpotifyTrack], +): """A collection representing the full spotify library. It is not possible to add or remove items from this collection. Also iteration diff --git a/plistsync/services/tidal/library.py b/plistsync/services/tidal/library.py index fc1ca849..8a65724f 100644 --- a/plistsync/services/tidal/library.py +++ b/plistsync/services/tidal/library.py @@ -15,7 +15,10 @@ from .track import TidalTrack -class TidalLibraryCollection(LibraryCollection[TidalTrack], GlobalLookup): +class TidalLibraryCollection( + LibraryCollection[TidalTrack, TidalPlaylistCollection], + GlobalLookup[TidalTrack], +): """A collection of Tidal library items.""" api: TidalApi diff --git a/plistsync/services/traktor/library.py b/plistsync/services/traktor/library.py index 57b41564..841321d8 100644 --- a/plistsync/services/traktor/library.py +++ b/plistsync/services/traktor/library.py @@ -22,7 +22,11 @@ from lxml.etree import _Element, _ElementTree -class NMLLibraryCollection(LibraryCollection, TrackStream, LocalLookup): +class NMLLibraryCollection( + LibraryCollection[NMLTrack, NMLPlaylistCollection], + TrackStream[NMLTrack], + LocalLookup[NMLTrack], +): """A Traktor NML collection. Allows to parse and interact with a Traktor NML file. I.e. traktor export playlist diff --git a/plistsync/services/traktor/playlist.py b/plistsync/services/traktor/playlist.py index 90cd54da..404e61f1 100644 --- a/plistsync/services/traktor/playlist.py +++ b/plistsync/services/traktor/playlist.py @@ -23,7 +23,7 @@ from .library import NMLLibraryCollection -class NMLPlaylistCollection(PlaylistCollection, LocalLookup): +class NMLPlaylistCollection(PlaylistCollection[NMLPlaylistTrack], LocalLookup): """A Traktor NML playlist collection. Traktor playlists use file paths as the identifiers. diff --git a/pyproject.toml b/pyproject.toml index 65f42428..d8cfd4e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,7 @@ fixable = ["ALL"] "W", "E501", # long lines ] -"**/__main__.py" = ["D103"] +"**__main__.py" = ["D103"] # ignore import order in init files # to allow dependency error handling @@ -165,6 +165,9 @@ warn_unused_configs = true # disallow_incomplete_defs = true # warn_return_any = true +# Symlink is checked twice if not excluded here +exclude = "(docs/examples)" + [[tool.mypy.overrides]] module = ["nest_asyncio.*"] ignore_missing_imports = true diff --git a/tests/services/plex/test_collection.py b/tests/services/plex/test_collection.py index 519bd699..606d30a9 100644 --- a/tests/services/plex/test_collection.py +++ b/tests/services/plex/test_collection.py @@ -1,11 +1,12 @@ from pathlib import Path -from collections.abc import Iterable, Iterator +from collections.abc import Iterable import pytest from plistsync.services.plex.playlist import PlexPlaylistCollection from plistsync.services.plex.track import PlexTrack from tests.abc import CollectionTestBase, LibraryCollectionTestBase +from plistsync.core.collection import LibraryCollection from plistsync.services.plex import PlexLibrarySectionCollection @@ -50,7 +51,7 @@ class TestPlexLibrarySectionCollection(LibraryCollectionTestBase): def setup(self, plex_library_collection): self.collection = plex_library_collection - def create_collection(self) -> Iterator[PlexLibrarySectionCollection]: + def create_collection(self, *args, **kwargs) -> Iterable[LibraryCollection]: """Create a PlexLibrarySectionCollection for testing. This method should create a collection with some dummy data. It must be implemented by the subclass. @@ -83,9 +84,8 @@ def unknown_playlists(self) -> list[tuple[str, str]]: def test_preload(self): """Test that preloading the library collection works.""" - library_collection: PlexLibrarySectionCollection = next( - self.create_collection() - ) + library_collection = next(iter(self.create_collection())) + assert isinstance(library_collection, PlexLibrarySectionCollection) library_collection.preload() assert library_collection._fetched is True, ( "Library collection should be marked as fetched after preload()" @@ -94,7 +94,6 @@ def test_preload(self): "Library collection should have tracks loaded after preload()" ) - # Iter should yield tracks after preload tracks = list(library_collection.tracks) assert len(tracks) > 0, "Library collection should yield tracks after preload()" @@ -103,7 +102,7 @@ def test_locations_property(self): for library_collection in self.create_collection(): locations = library_collection.locations assert isinstance(locations, Iterable), "Locations should be iterable" - assert len(locations) > 0, "Locations should not be empty" + assert len(list(locations)) > 0, "Locations should not be empty" assert all(isinstance(loc, Path) for loc in locations), ( "All locations should be Path instances" ) @@ -112,4 +111,4 @@ def test_get_playlist_raise(self): """Get playlist via path not allowed.""" with pytest.raises(ValueError): for library_collection in self.create_collection(): - library_collection.get_playlist(name="foo", id=121) # type:ignore + library_collection.get_playlist(name="foo", id=121)