diff --git a/CHANGES.md b/CHANGES.md index 308cc6a..cb42f46 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,19 @@ # Release notes +## 1.8.0 +Aug 03, 2023 + +- Refactor _make_playlist_ function for better performance +- Add standard path on unique argument +- Add _open_multimedia_file_ function +- Add `orderby-year` cli argument +- Add `join` cli argument +- Add mkpl logo +- Migrate to pyproject.toml installation +- Fix absolute name into _make_playlist_ function, refs #8 +- Fix check if playlist is not empty before writes it to file +- Fix check extension of file before open tags + ## 1.7.0 Jun 12, 2023 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 83dcafa..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include __info__.py \ No newline at end of file diff --git a/README.md b/README.md index 6ac0f2c..dceaad3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# ``make_playlist``: Make playlist command line tool +# ``make_playlist``: Playlist maker + +mkpl ``mkpl`` is a _command line tool_ to create playlist files (**[M3U](https://en.wikipedia.org/wiki/M3U) format**). @@ -13,7 +15,7 @@ $ dnf copr enable matteoguadrini/mkpl $ dnf install python-make_playlist -y # for Red Hat and fedora $ git clone https://github.com/MatteoGuadrini/mkpl.git && cd mkpl -$ python setup.py install # for others +$ pip install . # for others ``` ## Command arguments @@ -32,7 +34,8 @@ $ python setup.py install # for others | -t | --title | Playlist title | Title string | | -g | --encoding | Text encoding | UTF-8,ASCII,UNICODE | | -I | --image | Playlist image | Image path | -| -l | --link | Add remote file links | Links | +| -l | --link | Add local or remote files | Files | +| -j | --join | Join one or more other playlist files | Playlist files | | -r | --recursive | Recursive search | | | -a | --absolute | Absolute file name | | | -s | --shuffle | Casual order | | @@ -44,6 +47,7 @@ $ python setup.py install # for others | -o | --orderby-name | Order playlist files by name | | | -O | --orderby-date | Order playlist files by creation date | | | -T | --orderby-track | Order playlist files by track | | +| -y | --orderby-year | Order playlist files by year | | ## Examples @@ -142,14 +146,20 @@ $ python setup.py install # for others ... ``` -15. Sort playlist files by name (`-o`), by creation date (`-O`) or by track number (`-T`): +15. Sort playlist files by name (`-o`), by creation date (`-O`), by track number (`-T`) or by year (`-y`): ```bash mkpl -d "new_collection" -r "my music.m3u" -o mkpl -d "new_collection" -r "my music.m3u" -O mkpl -d "new_collection" -r "my music.m3u" -T + mkpl -d "new_collection" -r "my music.m3u" -y ``` +16. Join the _"First playlist.m3u"_ and _"Second playlist.m3u8"_ with new **"Third playlist.m3u"**: + + ```bash + mkpl -d "new_collection" -r "Third playlist" -j "First playlist.m3u" "Second playlist.m3u8" + ## Use it like Python module `mkpl` can also be used as a Python module to customize your scripts. @@ -159,8 +169,8 @@ from make_playlist import * # Prepare playlist list: find multimedia files with name starts between a and f playlist = make_playlist('/Music/collections', - '^[a-f].*', ('mp3', 'mp4', 'aac'), + '^[a-f].*', recursive=True, unique=True) @@ -185,8 +195,6 @@ The Telethon Foundation is a non-profit organization recognized by the Ministry They were born in 1990 to respond to the appeal of patients suffering from rare diseases. Come today, we are organized to dare to listen to them and answers, every day of the year. - Telethon - [Adopt the future](https://www.ioadottoilfuturo.it/) diff --git a/__info__.py b/__info__.py deleted file mode 100644 index da4f3bb..0000000 --- a/__info__.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -# -*- encoding: utf-8 -*- -# vim: se ts=4 et syn=python: - -# created by: matteo.guadrini -# __info__ -- mkpl -# -# Copyright (C) 2023 Matteo Guadrini -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Information variable used by modules on this package.""" - -__version__ = '1.7.0' -__author__ = 'Matteo Guadrini' -__email__ = 'matteo.guadrini@hotmail.it' -__homepage__ = 'https://github.com/MatteoGuadrini/mkpl' diff --git a/img/mkpl_logo.svg b/img/mkpl_logo.svg new file mode 100644 index 0000000..8291ad0 --- /dev/null +++ b/img/mkpl_logo.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/mkpl.py b/mkpl.py index 9d33ef8..32a8ada 100644 --- a/mkpl.py +++ b/mkpl.py @@ -24,6 +24,7 @@ # region imports import argparse +import os.path import re from filecmp import cmp from os.path import basename, dirname, exists, getctime, getsize, isdir, join, normpath @@ -32,7 +33,7 @@ from re import sub from string import capwords -from mutagen import File +from mutagen import File, MutagenError, id3 # endregion @@ -50,10 +51,27 @@ "flac", "alac", "opus", + "ape", + "webm", +} +VIDEO_FORMAT = { + "mp4", + "avi", + "xvid", + "divx", + "mpeg", + "mpg", + "mov", + "wmv", + "flv", + "vob", + "asf", + "m4v", + "3gp", + "f4a", } -VIDEO_FORMAT = {"mp4", "avi", "xvid", "divx", "mpeg", "mpg", "mov", "wmv"} FILE_FORMAT = AUDIO_FORMAT.union(VIDEO_FORMAT) -__version__ = "1.7.0" +__version__ = "1.8.0" # endregion @@ -66,7 +84,7 @@ def get_args(): global FILE_FORMAT parser = argparse.ArgumentParser( - description="Command line tool to create media playlists in M3U format.", + description="Command line tool to creates playlist file in M3U format.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, epilog="See latest release from https://github.com/MatteoGuadrini/mkpl", ) @@ -99,7 +117,7 @@ def get_args(): metavar="FORMAT", ) parser.add_argument( - "-p", "--pattern", help="Regular expression inclusion pattern", default=".*" + "-p", "--pattern", help="Regular expression inclusion pattern", default=None ) parser.add_argument( "-f", @@ -131,8 +149,17 @@ def get_args(): parser.add_argument( "-l", "--link", - help="Add remote file links", + help="Add local or remote file links", + nargs=argparse.ONE_OR_MORE, + metavar='FILES', + default=[], + ) + parser.add_argument( + "-j", + "--join", + help="Join one or more other playlist files", nargs=argparse.ONE_OR_MORE, + metavar='PLAYLISTS', default=[], ) parser.add_argument( @@ -174,6 +201,12 @@ def get_args(): help="Order playlist files by track", action="store_true", ) + orderby_group.add_argument( + "-y", + "--orderby-year", + help="Order playlist files by year", + action="store_true", + ) args = parser.parse_args() @@ -241,11 +274,26 @@ def file_in_playlist(playlist, file, root=None): # Check if absolute path in playlist if root: f = join(root, f) + # Make standard the path + f = unix_to_dos(f, viceversa=True) # Compare two files if cmp(f, file): return True +def join_playlist(playlist, *others): + """Join current playlist with others""" + for file in others: + try: + # open playlist, remove extensions and extend current playlist file + lines = open(file).readlines() + playlist.extend([line.rstrip() for line in lines if not line.startswith('#')]) + except FileNotFoundError: + print(f"warning: {file} file not found") + except OSError as err: + print(f"warning: {file} generated error: {err}") + + def report_issue(exc): """Report issue""" print( @@ -256,32 +304,59 @@ def report_issue(exc): exit(1) +def open_multimedia_file(path): + """Open multimedia file + + :param path: multimedia file to open + """ + try: + file = File(path) + except MutagenError: + print(f"warning: file '{path}' loading failed") + return False + return file + + def get_track(file): - """Sort file by track""" - file = File(file) - if hasattr(file, "tags"): - return file.tags.get("TRCK", "0")[0] + """Get file by track for sort""" + file = open_multimedia_file(file) + if file and hasattr(file, "tags"): + default = id3.TRCK(text="0") + return file.tags.get("TRCK", default)[0] + + +def get_year(file): + """Get file by year for sort""" + file = open_multimedia_file(file) + if file and hasattr(file, "tags"): + default = id3.TDOR(text="0") + return file.tags.get("TDOR", default)[0] def find_pattern(pattern, path): """Find patter in a file and tags""" - file = File(path) + global AUDIO_FORMAT + # Create compiled pattern if not isinstance(pattern, re.Pattern): pattern = re.compile(pattern) # Check pattern into filename - if pattern.findall(file.filename): + if pattern.findall(path): return True - # Check supports of ID3 tagsadd compiled pattern - if hasattr(file, "ID3"): - # Check pattern into title - for title in file.tags.get("TIT2"): - if pattern.findall(title): - return True - # Check pattern into album - for album in file.tags.get("TALB"): - if pattern.findall(album): - return True + # Check type of file + ext = os.path.splitext(path)[1].replace('.', '').lower() + if ext in AUDIO_FORMAT: + file = open_multimedia_file(path) + # Check supports of ID3 tagsadd compiled pattern + if file and hasattr(file, "ID3"): + # Check pattern into title + if file.tags.get("TIT2"): + if pattern.findall(file.tags.get("TIT2")[0]): + return True + # Check pattern into album + if file.tags.get("TALB"): + if pattern.findall(file.tags.get("TALB")[0]): + return True def vprint(verbose, *messages): @@ -290,6 +365,21 @@ def vprint(verbose, *messages): print("debug:", *messages) +def unix_to_dos(path, viceversa=False): + """Substitute folder separator with windows separator + + :param path: path to substitute folder separator + :param viceversa: dos to unix + """ + if viceversa: + old_sep = r"\\" + new_sep = "/" + else: + old_sep = "/" + new_sep = r"\\" + return sub(old_sep, new_sep, path) + + def write_playlist( playlist, open_mode, @@ -302,33 +392,35 @@ def write_playlist( verbose=False, ): """Write playlist into file""" - with open( - playlist, - mode=open_mode, - encoding="UTF-8" if encoding == "UNICODE" else encoding, - errors="ignore", - ) as pl: - if image and enabled_extensions: - vprint(verbose, f"set image {image}") - joined_string = f"\n#EXTIMG: {image}\n" - else: - joined_string = "\n" - end_file_string = "\n" - # Write extensions if exists - if ext_part: - pl.write("\n".join(files[:ext_part]) + joined_string) - # Write all multimedia files - vprint(verbose, f"write playlist {pl.name}") - pl.write(joined_string.join(files[ext_part:max_tracks]) + end_file_string) + if playlist: + with open( + playlist, + mode=open_mode, + encoding="UTF-8" if encoding == "UNICODE" else encoding, + errors="ignore", + ) as pl: + if image and enabled_extensions: + vprint(verbose, f"set image {image}") + joined_string = f"\n#EXTIMG: {image}\n" + else: + joined_string = "\n" + end_file_string = "\n" + # Write extensions if exists + if ext_part: + pl.write("\n".join(files[:ext_part]) + joined_string) + # Write all multimedia files + vprint(verbose, f"write playlist {pl.name}") + pl.write(joined_string.join(files[ext_part:max_tracks]) + end_file_string) def make_playlist( directory, - pattern, file_formats, + pattern=None, sortby_name=False, sortby_date=False, sortby_track=False, + sortby_year=False, recursive=False, exclude_dirs=None, unique=False, @@ -355,28 +447,32 @@ def make_playlist( # Check recursive folder = "**/*" if recursive else "*" files = path.glob(folder + f".{fmt}") + # Process found files for file in files: + # Get size of file + size = file.stat().st_size + # Check absolute file names + file = str(file.resolve()) if absolute else str(file) + # Check file match pattern + if pattern: + # Check re pattern + compiled_pattern = re.compile(pattern) + if not find_pattern(compiled_pattern, file): + continue # Check if in exclude dirs - if any([e_path in str(file) for e_path in exclude_dirs]): + if any([e_path in file for e_path in exclude_dirs]): continue # Check if file is in playlist if unique: if file_in_playlist( - filelist, str(file), root=root if not absolute else None + filelist, file, root=root if not absolute else None ): continue - # Get size of file - size = file.stat().st_size - # Check absolute file names - file_for_pattern = str(file) - file = str(file) if absolute else str(file.relative_to(path.parent)) - # Check re pattern - compiled_pattern = re.compile(pattern) - if find_pattern(compiled_pattern, file_for_pattern): - # Check file size - if size >= min_size: - vprint(verbose, f"add multimedia file {file}") - filelist.append(sub("/", r"\\", file) if windows else file) + # Check file size + if size <= min_size: + continue + vprint(verbose, f"add multimedia file {file}") + filelist.append(unix_to_dos(file) if windows else file) # Check sort if sortby_name: filelist = sorted(filelist) @@ -384,6 +480,8 @@ def make_playlist( filelist = sorted(filelist, key=getctime) elif sortby_track: filelist = sorted(filelist, key=get_track) + elif sortby_year: + filelist = sorted(filelist, key=get_year) return filelist @@ -430,6 +528,10 @@ def add_extension(filelist, cli_args, verbose=False): def _process_playlist(files, cli_args, other_playlist=None): """Private function cli only for process arguments and make playlist""" + # Join other playlist files + if cli_args.join: + join_playlist(files, *cli_args.join) + # Add link files.extend(cli_args.link) @@ -477,11 +579,12 @@ def main(): for directory in args.directories: directory_files = make_playlist( directory, - args.pattern, FILE_FORMAT, + args.pattern, sortby_name=args.orderby_name, sortby_date=args.orderby_date, sortby_track=args.orderby_track, + sortby_year=args.orderby_year, recursive=args.recursive, exclude_dirs=args.exclude_dirs, unique=args.unique, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..13409d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ["mkpl"] + +[project] +name = "make_playlist" +version = "1.8.0" +readme = "README.md" + +authors = [{ name = "Matteo Guadrini", email = "matteo.guadrini@hotmail.it" }] +maintainers = [ + { name = "Matteo Guadrini", email = "matteo.guadrini@hotmail.it" }, +] + +description = "Make M3U format playlist from command line." +requires-python = ">=3.6" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", +] +dependencies = ["mutagen"] + +[project.scripts] +mkpl = "mkpl:main" +make_playlist = "mkpl:main" + +[project.urls] +homepage = "https://github.com/MatteoGuadrini/mkpl" +documentation = "https://matteoguadrini.github.io/mkpl/" +changelog = "https://github.com/MatteoGuadrini/mkpl/blob/master/CHANGES.md" \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 4e7a629..0000000 --- a/setup.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -# -*- encoding: utf-8 -*- -# vim: se ts=4 et syn=python: - -# created by: matteo.guadrini -# setup -- mkpl -# -# Copyright (C) 2023 Matteo Guadrini -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import __info__ -from setuptools import setup - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name='make_playlist', - version=__info__.__version__, - url=__info__.__homepage__, - project_urls={ - 'Documentation': __info__.__homepage__, - 'GitHub Project': __info__.__homepage__, - 'Issue Tracker': __info__.__homepage__ + '/issues' - }, - install_requires=["mutagen"], - license='GNU General Public License v3.0', - author=__info__.__author__, - author_email=__info__.__email__, - maintainer=__info__.__author__, - maintainer_email=__info__.__email__, - description='Make M3U format playlist from command line', - long_description=long_description, - long_description_content_type="text/markdown", - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Operating System :: OS Independent" - ], - entry_points={ - 'console_scripts': [ - 'mkpl = mkpl:main', - 'make_playlist = mkpl:main', - ] - }, - python_requires='>=3.5' -)