14 Commits

Author SHA1 Message Date
a646fd2395 delete .github
Some checks failed
Tests / test (ubuntu-latest, 3.9) (push) Failing after 15m43s
Tests / test (ubuntu-latest, 3.10) (push) Failing after 1m15s
Tests / test (ubuntu-latest, 3.8) (push) Failing after 23m0s
Tests / test (macos-latest, 3.10) (push) Has been cancelled
Tests / test (macos-latest, 3.8) (push) Has been cancelled
Tests / test (macos-latest, 3.9) (push) Has been cancelled
Tests / test (windows-latest, 3.10) (push) Has been cancelled
Tests / test (windows-latest, 3.8) (push) Has been cancelled
Tests / test (windows-latest, 3.9) (push) Has been cancelled
2025-03-09 04:50:07 -07:00
9eee991889 update readme
Some checks failed
Tests / test (macos-latest, 3.10) (push) Waiting to run
Tests / test (macos-latest, 3.8) (push) Waiting to run
Tests / test (macos-latest, 3.9) (push) Waiting to run
Tests / test (ubuntu-latest, 3.9) (push) Waiting to run
Tests / test (windows-latest, 3.10) (push) Waiting to run
Tests / test (windows-latest, 3.8) (push) Waiting to run
Tests / test (windows-latest, 3.9) (push) Waiting to run
Tests / test (ubuntu-latest, 3.8) (push) Has been cancelled
Tests / test (ubuntu-latest, 3.10) (push) Has been cancelled
2025-03-09 04:48:09 -07:00
224fbde0a4 update
Some checks failed
Tests / test (macos-latest, 3.10) (push) Waiting to run
Tests / test (macos-latest, 3.8) (push) Waiting to run
Tests / test (macos-latest, 3.9) (push) Waiting to run
Tests / test (ubuntu-latest, 3.9) (push) Waiting to run
Tests / test (windows-latest, 3.10) (push) Waiting to run
Tests / test (windows-latest, 3.8) (push) Waiting to run
Tests / test (windows-latest, 3.9) (push) Waiting to run
Tests / test (ubuntu-latest, 3.10) (push) Has been cancelled
Tests / test (ubuntu-latest, 3.8) (push) Has been cancelled
2025-03-09 04:44:55 -07:00
ad11faf1b0 update
Some checks failed
Tests / test (ubuntu-latest, 3.8) (push) Failing after 24m2s
Tests / test (ubuntu-latest, 3.10) (push) Failing after 24m4s
Tests / test (ubuntu-latest, 3.9) (push) Failing after 10m52s
Tests / test (macos-latest, 3.10) (push) Has been cancelled
Tests / test (macos-latest, 3.8) (push) Has been cancelled
Tests / test (macos-latest, 3.9) (push) Has been cancelled
Tests / test (windows-latest, 3.10) (push) Has been cancelled
Tests / test (windows-latest, 3.8) (push) Has been cancelled
Tests / test (windows-latest, 3.9) (push) Has been cancelled
2025-03-08 23:52:40 -08:00
a66e3ab455 Merge pull request 'fix workflow' (#2) from update-workflow into master
Reviewed-on: #2
2025-03-07 05:23:21 -08:00
7e221f74bd fix workflow
All checks were successful
Build and Upload Python Package / build (pull_request) Successful in 4m36s
2025-03-07 05:22:32 -08:00
14d680da18 Merge pull request 'change workflow to be merge based' (#1) from update-workflow into master
Reviewed-on: #1
2025-03-07 05:20:55 -08:00
d23e713d7a change workflow to be merge based 2025-03-07 05:20:11 -08:00
550e9c0ac8 fix play command for files 2025-03-07 05:14:58 -08:00
9cec56ef73 update fzf list and sqash some bugs 2025-03-07 05:05:02 -08:00
c7cab59898 fix bug 2025-03-07 04:38:00 -08:00
b19ed0efea fix import 2025-03-07 04:34:51 -08:00
7780c9e640 update readme 2025-03-07 04:23:20 -08:00
281cb38af7 add license and upate workflow 2025-03-07 04:21:55 -08:00
22 changed files with 3430 additions and 204 deletions

View File

@@ -1,8 +1,9 @@
name: Build and Upload Python Package name: Build and Upload Python Package
on: on:
push: pull_request:
branches: branches:
- master - master
types: [closed]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -0,0 +1,140 @@
name: Create Release and Publish
on:
workflow_dispatch:
inputs:
version_bump:
description: "Type of version bump"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
custom_version:
description: "Custom version (if specified, ignores version_bump)"
required: false
push:
tags:
- "v*.*.*"
jobs:
create-release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Check out code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel semver
- name: Determine current version
id: current_version
run: |
CURRENT_VERSION=$(grep -E "__version__\s*=\s*['\"]([^'\"]+)['\"]" src/jimaku_dl/cli.py | cut -d'"' -f2)
echo "Current version: $CURRENT_VERSION"
echo "CURRENT_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV
- name: Calculate new version
id: new_version
run: |
if [ -n "${{ github.event.inputs.custom_version }}" ]; then
NEW_VERSION="${{ github.event.inputs.custom_version }}"
echo "Using custom version: $NEW_VERSION"
else
BUMP_TYPE="${{ github.event.inputs.version_bump }}"
CURRENT="${{ env.CURRENT_VERSION }}"
if [ "$BUMP_TYPE" = "patch" ]; then
MAJOR=$(echo $CURRENT | cut -d. -f1)
MINOR=$(echo $CURRENT | cut -d. -f2)
PATCH=$(echo $CURRENT | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
elif [ "$BUMP_TYPE" = "minor" ]; then
MAJOR=$(echo $CURRENT | cut -d. -f1)
MINOR=$(echo $CURRENT | cut -d. -f2)
NEW_MINOR=$((MINOR + 1))
NEW_VERSION="$MAJOR.$NEW_MINOR.0"
elif [ "$BUMP_TYPE" = "major" ]; then
MAJOR=$(echo $CURRENT | cut -d. -f1)
NEW_MAJOR=$((MAJOR + 1))
NEW_VERSION="$NEW_MAJOR.0.0"
else
echo "Invalid bump type: $BUMP_TYPE"
exit 1
fi
echo "Bumping $BUMP_TYPE version: $CURRENT → $NEW_VERSION"
fi
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
- name: Update version in files
run: |
# Update version in cli.py instead of __init__.py
sed -i "s/__version__ = \"${{ env.CURRENT_VERSION }}\"/__version__ = \"${{ env.NEW_VERSION }}\"/g" src/jimaku_dl/cli.py
# Still update setup.cfg if it exists
if [ -f "setup.cfg" ]; then
sed -i "s/version = ${{ env.CURRENT_VERSION }}/version = ${{ env.NEW_VERSION }}/g" setup.cfg
fi
echo "Updated version to ${{ env.NEW_VERSION }} in code files"
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
CHANGELOG=$(git log --pretty=format:"* %s (%h)" --no-merges)
else
CHANGELOG=$(git log $PREV_TAG..HEAD --pretty=format:"* %s (%h)" --no-merges)
fi
if [ -z "$CHANGELOG" ]; then
CHANGELOG="* Bug fixes and improvements"
fi
echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Commit version changes
run: |
git config --local user.email "action@gitea.suda.codes"
git config --local user.name "Gitea Action"
git add src/jimaku_dl/cli.py
if [ -f "setup.cfg" ]; then
git add setup.cfg
fi
git commit -m "Bump version to ${{ env.NEW_VERSION }}"
git tag -a "v${{ env.NEW_VERSION }}" -m "Release v${{ env.NEW_VERSION }}"
git push --follow-tags
- name: Create Gitea Release
id: create_release
uses: softprops/action-gh-release@v1
with:
tag_name: "v${{ env.NEW_VERSION }}"
name: "Release v${{ env.NEW_VERSION }}"
body: |
## Changes in this release
${{ steps.changelog.outputs.CHANGELOG }}
draft: false
prerelease: false
token: ${{ secrets.ACCESS_KEY }}

59
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,59 @@
name: Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: [3.8, 3.9, "3.10"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-cov pytest-mock flake8 black isort
- name: Lint with flake8
run: |
flake8 src/jimaku_dl
- name: Check formatting with black
run: |
black --check src/jimaku_dl
- name: Check imports with isort
run: |
isort --check src/jimaku_dl
- name: Test with pytest
run: |
pytest --cov=jimaku_dl --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: false

6
.gitignore vendored
View File

@@ -3,3 +3,9 @@
dist dist
src/jimaku_dl.egg-info src/jimaku_dl.egg-info
src/jimaku_dl/__pycache__ src/jimaku_dl/__pycache__
tests/__pycache__/
.pytest_cache
.env
.coverage
coverage.xml
junit.xml

675
LICENSE Normal file
View File

@@ -0,0 +1,675 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

146
README.md
View File

@@ -1,83 +1,131 @@
# Jimaku Downloader # Jimaku Downloader
A Python package to download japanese subtitles for anime from Jimaku.cc <div align="center">
<a href="">[![AUR License](https://img.shields.io/aur/license/python-jimaku-dl)](https://aur.archlinux.org/packages/python-jimaku-dl)</a>
<a href="">[![GitHub Release](https://img.shields.io/github/v/release/ksyasuda/jimaku-dl)](https://github.com/ksyasuda/jimaku-dl)</a>
<a href="">[![AUR Last Modified](https://img.shields.io/aur/last-modified/python-jimaku-dl)](https://aur.archlinux.org/packages/python-jimaku-dl)</a>
<a href="">[![codecov](https://codecov.io/gh/ksyasuda/jimaku-dl/graph/badge.svg?token=5S5NRSPVHT)](https://codecov.io/gh/ksyasuda/jimaku-dl)</a>
A tool for downloading Japanese subtitles for anime from <a href="https://jimaku.cc" target="_blank" rel="noopener noreferrer">Jimaku</a>
<p>
<video controls muted src="https://github.com/user-attachments/assets/3723866f-4e7d-4f89-8b55-17f2fb6fa6be"></video>
</p>
</div>
## Features ## Features
- Search for subtitles using AniList IDs - Queries AniList for anime titles
- Supports both individual files and directories - Select subtitle entries from Jimaku
- Interactive subtitle selection using fzf - Download subtitles to a specified directory
- Auto-detects anime from filenames with season and episode numbers - Launch MPV with the downloaded subtitles
- Recursively checks parent directories for anime titles - Supports both file and directory inputs
- Optional MPV playback with downloaded subtitles - Support for downloading multiple subtitle files
- Caches AniList IDs to reduce API calls
## Installation ## Installation
```bash You can install Jimaku Downloader using pip
# From PyPI
pip install jimaku-dl
# From source ```sh
git clone https://github.com/InsaneDesperado/jimaku-dl.git pip install jimaku-dl
cd jimaku-dl ```
pip install -e .
### Arch Linux
Arch Linux users can install
<a href="https://aur.archlinux.org/packages/python-jimaku-dl" target="_blank">python-jimaku-dl</a>
from the AUR
```sh
paru -S python-jimaku-dl
# or
yay -S python-jimaku-dl
``` ```
## Usage ## Usage
### Command Line ### Command Line Interface
```bash The main entry point for Jimaku Downloader is the `jimaku-dl` command. Here are some examples of how to use it:
# Download subtitles for a video file
jimaku-dl /path/to/your/anime.S01E02.mkv
# Download subtitles for an entire series (directory mode) ```sh
jimaku-dl /path/to/your/anime/directory/ # Download subtitles for a single video file
jimaku-dl /path/to/video.mkv
# Specify a different destination directory # Download subtitles for a directory
jimaku-dl /path/to/your/anime.mkv --dest /path/to/subtitles/ jimaku-dl /path/to/anime/directory
# Play the video with MPV after downloading subtitles # Specify a custom destination directory
jimaku-dl /path/to/your/anime.mkv --play jimaku-dl /path/to/video.mkv --dest /custom/path
# Set API token via command line # Launch MPV with the downloaded subtitles
jimaku-dl /path/to/your/anime.mkv --api-token YOUR_TOKEN jimaku-dl /path/to/video.mkv --play
```
### Environment Variables # Specify an AniList ID directly
jimaku-dl /path/to/video.mkv --anilist-id 123456
You can set your Jimaku API token using the `JIMAKU_API_TOKEN` environment variable: # Set the Jimaku API token
jimaku-dl /path/to/video.mkv --token your_api_token
```bash # Set the logging level
export JIMAKU_API_TOKEN=your_api_token jimaku-dl /path/to/video.mkv --log-level DEBUG
``` ```
### Python API ### Python API
You can also use Jimaku Downloader as a Python library:
```python ```python
from jimaku_downloader import JimakuDownloader from jimaku_dl.downloader import JimakuDownloader
# Create a downloader instance
downloader = JimakuDownloader(api_token="your_api_token", log_level="INFO") downloader = JimakuDownloader(api_token="your_api_token", log_level="INFO")
downloaded_files = downloader.download_subtitles("/path/to/video.mkv", dest_dir="/custom/path", play=True)
# Download subtitles print(f"Downloaded files: {downloaded_files}")
downloaded_files = downloader.download_subtitles(
media_path="/path/to/your/anime.mkv",
dest_dir="/path/to/save/subtitles/", # Optional
play=True # Optional: play with MPV after downloading
)
print(f"Downloaded {len/downloaded_files)} subtitle files")
``` ```
## Requirements ## File Naming
- Python 3.8 or higher Jimaku Downloader supports various file naming conventions to extract show title, season, and episode information. It is recommended to follow the [Trash Guides recommended naming schema](https://trash-guides.info/Sonarr/Sonarr-recommended-naming-scheme/#recommended-naming-scheme) for best results.
- `fzf` command-line utility for interactive selection
- `mpv` (optional, for playback functionality) ### Examples
- A valid Jimaku.cc API token
- `Show Title - S01E02 - Episode Name [1080p].mkv`
- `Show.Name.S01E02.1080p.mkv`
- `Show_Name_S01E02_HEVC.mkv`
- `/path/to/Show Name/Season-1/Show Name - 02 [1080p].mkv`
## Development
To contribute to Jimaku Downloader, follow these steps:
1. Clone the repository:
```sh
git clone https://github.com/yourusername/jimaku-dl.git
cd jimaku-dl
```
2. Create a virtual environment and activate it:
```sh
python -m venv venv
source venv/bin/activate # On Windows, use `venv\Scripts\activate`
```
3. Install the dependencies:
```sh
pip install -r requirements.txt
pip install -r requirements_dev.txt
```
4. Run the tests:
```sh
pytest
```
## License ## License
MIT Jimaku Downloader is licensed under the GPLv3 License. See the [LICENSE](LICENSE) file for more information.

9
pytest.ini Normal file
View File

@@ -0,0 +1,9 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
unit: Unit tests
integration: Integration tests
api: Tests that mock API calls

6
requirements_dev.txt Normal file
View File

@@ -0,0 +1,6 @@
pytest>=7.3.1
pytest-cov>=4.1.0
pytest-mock>=3.10.0
flake8>=6.0.0
black>=23.3.0
mypy>=1.3.0

View File

@@ -1,14 +1,14 @@
[metadata] [metadata]
name = jimaku-dl name = jimaku-dl
version = 0.1.0 version = 0.1.2
author = InsaneDesperado author = sudacode
author_email = insane@lmaoxd.lol author_email = suda@sudacode.com
description = Download japanese anime subtitles from Jimaku description = Download japanese anime subtitles from Jimaku
long_description = file: README.md long_description = file: README.md
long_description_content_type = text/markdown long_description_content_type = text/markdown
url = https://github.com/InsaneDesperado/jimaku-dl url = https://github.com/ksyasuda/jimaku-dl
project_urls = project_urls =
Bug Tracker = https://github.com/InsaneDesperado/jimaku-dl/issues Bug Tracker = https://github.com/ksyasuda/jimaku-dl/issues
classifiers = classifiers =
Programming Language :: Python :: 3 Programming Language :: Python :: 3
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8

View File

@@ -5,7 +5,7 @@ This package provides functionality to search for, select, and download
subtitles for anime media files or directories. subtitles for anime media files or directories.
""" """
__version__ = "0.1.0" __version__ = "0.1.1"
from .downloader import JimakuDownloader from .downloader import JimakuDownloader

View File

@@ -3,48 +3,72 @@
from argparse import ArgumentParser from argparse import ArgumentParser
from os import environ from os import environ
from sys import exit from sys import exit as sysexit
from jimaku_downloader.downloader import JimakuDownloader from jimaku_dl.downloader import JimakuDownloader
__version__ = "0.1.2"
def main(): def main():
""" """
Command line entry point for Jimaku subtitle downloader. Command line entry point for Jimaku subtitle downloader.
""" """
parser = ArgumentParser( parser = ArgumentParser(description="Download anime subtitles from Jimaku")
description="Download anime subtitles from Jimaku using the AniList API."
)
parser.add_argument("media_path", help="Path to the media file or directory") parser.add_argument("media_path", help="Path to the media file or directory")
parser.add_argument( parser.add_argument(
"-d",
"--dest", "--dest",
help="Directory to save downloaded subtitles (default: same directory as video/input directory)", help=(
"Directory to save downloaded subtitles "
"(default: same directory as video/input directory)"
),
) )
parser.add_argument( parser.add_argument(
"--play", action="store_true", help="Launch MPV with the subtitle(s) loaded" "-p",
"--play",
action="store_true",
help="Launch MPV with the subtitle(s) loaded",
) )
parser.add_argument( parser.add_argument(
"--api-token", "-t",
"--token",
dest="api_token",
default=environ.get("JIMAKU_API_TOKEN", ""), default=environ.get("JIMAKU_API_TOKEN", ""),
help="Jimaku API token (or set JIMAKU_API_TOKEN env var)", help="Jimaku API token (or set JIMAKU_API_TOKEN env var)",
) )
parser.add_argument( parser.add_argument(
"-l",
"--log-level", "--log-level",
default="INFO", default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set the logging level (default: INFO)", help="Set the logging level (default: INFO)",
) )
parser.add_argument(
"-a",
"--anilist-id",
type=int,
help="Specify AniList ID directly instead of searching",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"jimaku-dl {__version__}",
help="Show program version and exit",
)
args = parser.parse_args() args = parser.parse_args()
try: try:
# Create downloader instance
downloader = JimakuDownloader( downloader = JimakuDownloader(
api_token=args.api_token, log_level=args.log_level api_token=args.api_token, log_level=args.log_level
) )
# Download subtitles
downloaded_files = downloader.download_subtitles( downloaded_files = downloader.download_subtitles(
media_path=args.media_path, dest_dir=args.dest, play=args.play media_path=args.media_path,
dest_dir=args.dest,
play=args.play,
anilist_id=args.anilist_id,
) )
if not downloaded_files: if not downloaded_files:
@@ -57,10 +81,13 @@ def main():
except ValueError as e: except ValueError as e:
print(f"Error: {str(e)}") print(f"Error: {str(e)}")
return 1 return 1
except KeyboardInterrupt:
print("\nOperation cancelled by user.")
return 1
except Exception as e: except Exception as e:
print(f"Unexpected error: {str(e)}") print(f"Unexpected error: {str(e)}")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
exit(main()) sysexit(main())

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from logging import Logger, basicConfig, getLogger from logging import Logger, basicConfig, getLogger
from os import environ from os import environ
from os.path import abspath, basename, dirname, exists, isdir, join, normpath, splitext from os.path import abspath, basename, dirname, exists, isdir, join, normpath
from re import IGNORECASE from re import IGNORECASE
from re import compile as re_compile from re import compile as re_compile
from re import search from re import search, sub
from subprocess import CalledProcessError from subprocess import CalledProcessError
from subprocess import run as subprocess_run from subprocess import run as subprocess_run
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
@@ -21,30 +21,28 @@ class JimakuDownloader:
subtitles for anime media files or directories. subtitles for anime media files or directories.
""" """
# API endpoints
ANILIST_API_URL = "https://graphql.anilist.co" ANILIST_API_URL = "https://graphql.anilist.co"
JIMAKU_SEARCH_URL = "https://jimaku.cc/api/entries/search" JIMAKU_SEARCH_URL = "https://jimaku.cc/api/entries/search"
JIMAKU_FILES_BASE = "https://jimaku.cc/api/entries" JIMAKU_FILES_BASE = "https://jimaku.cc/api/entries"
def __init__(self, api_token: Optional[str] = None, log_level: str = "INFO"): def __init__(self, api_token: Optional[str] = None, log_level: str = "INFO"):
""" """
Initialize the JimakuDownloader with API token and logging configuration. Initialize the JimakuDownloader with API token and logging
Parameters Parameters
---------- ----------
api_token : str, optional api_token : str, optional
Jimaku API token for authentication. If None, will try to get from JIMAKU_API_TOKEN env var Jimaku API token for authentication. If None, will try to get from
JIMAKU_API_TOKEN env var
log_level : str, default="INFO" log_level : str, default="INFO"
Logging level to use (DEBUG, INFO, WARNING, ERROR, CRITICAL) Logging level to use (DEBUG, INFO, WARNING, ERROR, CRITICAL)
""" """
# Set up logging
self.logger = self._setup_logging(log_level) self.logger = self._setup_logging(log_level)
# Initialize API token
self.api_token = api_token or environ.get("JIMAKU_API_TOKEN", "") self.api_token = api_token or environ.get("JIMAKU_API_TOKEN", "")
if not self.api_token: if not self.api_token:
self.logger.warning( self.logger.warning(
"No API token provided. Will need to be set before downloading." "No API token provided. " "Will need to be set before downloading."
) )
def _setup_logging(self, log_level: str) -> Logger: def _setup_logging(self, log_level: str) -> Logger:
@@ -107,30 +105,132 @@ class JimakuDownloader:
- season (int): Season number - season (int): Season number
- episode (int): Episode number - episode (int): Episode number
""" """
match = search(r"(.+?)[. _-]+[Ss](\d+)[Ee](\d+)", filename) # Clean up filename first to handle parentheses and brackets
clean_filename = filename
# Try Trash Guides anime naming schema first
# Format: {Series Title} - S{season:00}E{episode:00} - {Episode Title}
trash_guide_match = search(
r"(.+?)(?:\(\d{4}\))?\s*-\s*[Ss](\d+)[Ee](\d+)\s*-\s*.+",
basename(clean_filename),
)
if trash_guide_match:
title = trash_guide_match.group(1).strip()
season = int(trash_guide_match.group(2))
episode = int(trash_guide_match.group(3))
self.logger.debug(
"Parsed using Trash Guides format: %s, %s, %s",
f"{title=}",
f"{season=}",
f"{episode=}",
)
return title, season, episode
# Try to extract from directory structure following Trash Guides format
# Format: /path/to/{Series Title}/Season {season}/{filename}
parts = normpath(clean_filename).split("/")
if len(parts) >= 3 and "season" in parts[-2].lower():
# Get season from the Season XX directory
season_match = search(r"season\s*(\d+)", parts[-2].lower())
if season_match:
season = int(season_match.group(1))
# The show title is likely the directory name one level up
title = parts[-3]
# Try to get episode number from filename
pattern = r"[Ss](\d+)[Ee](\d+)|[Ee](?:pisode)"
pattern += r"?\s*(\d+)|(?:^|\s|[._-])(\d+)(?:\s|$|[._-])"
ep_match = search(
pattern,
parts[-1],
)
if ep_match:
episode_groups = ep_match.groups()
episode_str = next(
(g for g in episode_groups if g is not None), "1"
)
if ep_match.group(1) is not None and ep_match.group(2) is not None:
episode_str = ep_match.group(2)
episode = int(episode_str)
else:
episode = 1
self.logger.debug(
"Parsed from Trash Guides directory structure: %s, %s, %s",
f"{title=}",
f"{season=}",
f"{episode=}",
)
return title, season, episode
# Try the standard S01E01 format
match = search(r"(.+?)[. _-]+[Ss](\d+)[Ee](\d+)", clean_filename)
if match: if match:
title = match.group(1).replace(".", " ").strip() title = match.group(1).replace(".", " ").strip().replace("_", " ")
season = int(match.group(2)) season = int(match.group(2))
episode = int(match.group(3)) episode = int(match.group(3))
self.logger.debug(
"Parsed using S01E01 format: %s, %s, %s",
f"{title=}",
f"{season=}",
f"{episode}",
)
return title, season, episode return title, season, episode
else:
self.logger.warning("Could not parse filename automatically.") # Try to extract from paths like "Show Name/Season-1/Episode" format
title = input( parts = normpath(filename).split("/")
"Could not parse media title. Please enter show title: " if len(parts) >= 3:
).strip() # Check if the parent directory contains "Season" in the name
try: season_dir = parts[-2]
season = int( if "season" in season_dir.lower():
input("Enter season number (or 0 if not applicable): ").strip() srch = r"season[. _-]*(\d+)"
or "0" season_match = search(srch, season_dir.lower())
) if season_match:
episode = int( season = int(season_match.group(1))
input("Enter episode number (or 0 if not applicable): ").strip() # The show name is likely 2 directories up
or "0" title = parts[-3].replace(".", " ").strip()
) # Try to find episode number in the filename
except ValueError: ep_match = search(
self.logger.error("Invalid input.") r"[Ee](?:pisode)?[. _-]*(\d+)|[. _-](\d+)[. _-]",
raise ValueError("Invalid season or episode number") parts[-1],
return title, season, episode )
episode = int(
ep_match.group(1)
if ep_match and ep_match.group(1)
else (
ep_match.group(2) if ep_match and ep_match.group(2) else 1
)
)
self.logger.debug(
"Parsed from directory structure: %s, %s, %s",
f"{title=}",
f"{season=}",
f"{episode=}",
)
return title, season, episode
return self._prompt_for_title_info(filename)
def _prompt_for_title_info(self, filename: str) -> Tuple[str, int, int]:
"""
Prompt the user to manually enter show title and episode info.
"""
self.logger.warning("Could not parse filename automatically.")
print(f"\nFilename: {filename}")
print("Could not determine anime title and episode information.")
title = input("Please enter the anime title: ").strip()
try:
season = int(
input("Enter season number (or 0 if not applicable): ").strip() or "1"
)
episode = int(
input("Enter episode number " + "(or 0 if not applicable): ").strip()
or "1"
)
except ValueError:
self.logger.error("Invalid input.")
raise ValueError("Invalid season or episode number")
return title, season, episode
def parse_directory_name(self, dirname: str) -> Tuple[bool, str, int, int]: def parse_directory_name(self, dirname: str) -> Tuple[bool, str, int, int]:
""" """
@@ -150,15 +250,12 @@ class JimakuDownloader:
- season (int): Defaults to 1 - season (int): Defaults to 1
- episode (int): Defaults to 0 (indicating all episodes) - episode (int): Defaults to 0 (indicating all episodes)
""" """
# Clean up the directory name to use as the title
title = basename(dirname.rstrip("/")) title = basename(dirname.rstrip("/"))
# Skip empty titles or obviously non-anime directories
if not title or title in [".", "..", "/"]: if not title or title in [".", "..", "/"]:
self.logger.debug(f"Directory name '{title}' is not usable") self.logger.debug("Directory name '%s' is not usable", title)
return False, "", 1, 0 return False, "", 1, 0
# Skip common system directories
common_dirs = [ common_dirs = [
"bin", "bin",
"etc", "etc",
@@ -173,13 +270,13 @@ class JimakuDownloader:
] ]
if title.lower() in common_dirs: if title.lower() in common_dirs:
self.logger.debug( self.logger.debug(
f"Directory name '{title}' is a common system directory, skipping" "Directory name '%s' is a common system directory, skipping",
title,
) )
return False, "", 1, 0 return False, "", 1, 0
title = title.replace("_", " ").replace(".", " ").strip() title = title.replace("_", " ").replace(".", " ").strip()
# Check if the title seems usable (at least 3 characters)
if len(title) < 3: if len(title) < 3:
self.logger.debug( self.logger.debug(
f"Directory name '{title}' too short, likely not a show title" f"Directory name '{title}' too short, likely not a show title"
@@ -188,12 +285,11 @@ class JimakuDownloader:
self.logger.debug(f"Parsed title from directory name: {title}") self.logger.debug(f"Parsed title from directory name: {title}")
# For directories, assume season 1 and episode 0 (indicating all episodes)
return True, title, 1, 0 return True, title, 1, 0
def find_anime_title_in_path(self, path: str) -> Tuple[str, int, int]: def find_anime_title_in_path(self, path: str) -> Tuple[str, int, int]:
""" """
Recursively search for an anime title in the path, trying parent directories Recursively search for an anime title in the path
if necessary. if necessary.
Parameters Parameters
@@ -204,7 +300,7 @@ class JimakuDownloader:
Returns Returns
------- -------
tuple tuple
(title, season, episode) - anime title and defaults for season and episode (title, season, episode)
Raises Raises
------ ------
@@ -218,29 +314,27 @@ class JimakuDownloader:
success, title, season, episode = self.parse_directory_name(path) success, title, season, episode = self.parse_directory_name(path)
if success: if success:
self.logger.debug(f"Found anime title '{title}' from directory: {path}") self.logger.debug(
"Found anime title '%s' from directory: %s", title, path
)
return title, season, episode return title, season, episode
# Try parent directory
self.logger.debug(f"No anime title in '{path}', trying parent directory") self.logger.debug(f"No anime title in '{path}', trying parent directory")
parent_path = dirname(path) parent_path = dirname(path)
# Check if we're stuck (parent is same as current)
if parent_path == path: if parent_path == path:
break break
path = parent_path path = parent_path
# If we get here, we've reached root without finding a suitable title self.logger.error("Could not extract anime title from path: %s", original_path)
self.logger.error(
f"Could not extract anime title from directory path: {original_path}"
)
self.logger.error("Please specify a directory with a recognizable anime name") self.logger.error("Please specify a directory with a recognizable anime name")
raise ValueError(f"Could not find anime title in path: {original_path}") raise ValueError("Could not find anime title in path: " + f"{original_path}")
def load_cached_anilist_id(self, directory: str) -> Optional[int]: def load_cached_anilist_id(self, directory: str) -> Optional[int]:
""" """
Look for a file named '.anilist.id' in the given directory and return the AniList ID. Look for a file named '.anilist.id' in the given directory
and return the AniList ID.
Parameters Parameters
---------- ----------
@@ -264,7 +358,7 @@ class JimakuDownloader:
def save_anilist_id(self, directory: str, anilist_id: int) -> None: def save_anilist_id(self, directory: str, anilist_id: int) -> None:
""" """
Save the AniList ID to a file named '.anilist.id' in the given directory. Save the AniList ID to '.anilist.id' in the given directory
Parameters Parameters
---------- ----------
@@ -284,14 +378,16 @@ class JimakuDownloader:
except Exception as e: except Exception as e:
self.logger.warning(f"Could not save AniList cache file: {e}") self.logger.warning(f"Could not save AniList cache file: {e}")
def query_anilist(self, title: str) -> int: def query_anilist(self, title: str, season: Optional[int] = None) -> int:
""" """
Query AniList's GraphQL API for the given title and return its media ID. Query AniList's GraphQL API for the given title and return its ID.
Parameters Parameters
---------- ----------
title : str title : str
The anime title to search for The anime title to search for
season : int, optional
The season number to search for
Returns Returns
------- -------
@@ -312,28 +408,64 @@ class JimakuDownloader:
english english
native native
} }
synonyms
} }
} }
""" """
variables = {"search": title}
# Clean up the title to remove special characters and extra spaces
cleaned_title = sub(r"[^\w\s]", "", title).strip()
# Append season to the title if season is greater than 1
if season and season > 1:
cleaned_title += f" - Season {season}"
variables = {"search": cleaned_title}
try: try:
self.logger.debug(f"Sending AniList query for title: {title}") self.logger.debug("Querying AniList API for title: %s", title)
self.logger.debug(f"Query variables: {variables}")
response = requests_post( response = requests_post(
self.ANILIST_API_URL, json={"query": query, "variables": variables} self.ANILIST_API_URL,
json={"query": query, "variables": variables},
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
self.logger.debug(f"AniList response: {data}")
media = data.get("data", {}).get("Media") media = data.get("data", {}).get("Media")
if media: if media:
return media.get("id") anilist_id = media.get("id")
else: self.logger.info(f"Found AniList ID: {anilist_id}")
self.logger.error("AniList: No media found for title.") return anilist_id
raise ValueError(f"No media found on AniList for title: {title}")
# If all automatic methods fail, raise ValueError
self.logger.error(
f"AniList search failed for title: {title}, season: {season}"
)
raise ValueError(f"Could not find anime on AniList for title: {title}")
except Exception as e: except Exception as e:
self.logger.error(f"Error querying AniList: {e}") self.logger.error(f"Error querying AniList: {e}")
raise ValueError(f"Error querying AniList API: {str(e)}") raise ValueError(f"Error querying AniList API: {str(e)}")
def _prompt_for_anilist_id(self, title: str) -> int:
"""
Prompt the user to manually enter an AniList ID.
"""
print(f"\nPlease find the AniList ID for: {title}")
print("Visit https://anilist.co and search for your anime.")
print(
"The ID is the number in the URL, "
+ "e.g., https://anilist.co/anime/12345 -> ID is 12345"
)
while True:
try:
anilist_id = int(input("Enter AniList ID: ").strip())
return anilist_id
except ValueError:
print("Please enter a valid number.")
def query_jimaku_entries(self, anilist_id: int) -> List[Dict[str, Any]]: def query_jimaku_entries(self, anilist_id: int) -> List[Dict[str, Any]]:
""" """
Query the Jimaku API to list available subtitle entries. Query the Jimaku API to list available subtitle entries.
@@ -355,7 +487,8 @@ class JimakuDownloader:
""" """
if not self.api_token: if not self.api_token:
raise ValueError( raise ValueError(
"API token is required. Set it in the constructor or JIMAKU_API_TOKEN env var." "API token is required for downloading subtitles from Jimaku. "
"Set it in the constructor or JIMAKU_API_TOKEN env var."
) )
params = {"anilist_id": anilist_id} params = {"anilist_id": anilist_id}
@@ -403,7 +536,8 @@ class JimakuDownloader:
""" """
if not self.api_token: if not self.api_token:
raise ValueError( raise ValueError(
"API token is required. Set it in the constructor or JIMAKU_API_TOKEN env var." "API token is required for downloading subtitles from Jimaku. "
"Set it in the constructor or JIMAKU_API_TOKEN env var."
) )
url = f"{self.JIMAKU_FILES_BASE}/{entry_id}/files" url = f"{self.JIMAKU_FILES_BASE}/{entry_id}/files"
@@ -423,14 +557,14 @@ class JimakuDownloader:
raise ValueError(f"No files found for entry ID: {entry_id}") raise ValueError(f"No files found for entry ID: {entry_id}")
return files return files
except Exception as e: except Exception as e:
self.logger.error(f"Error querying files for entry {entry_id}: {e}") self.logger.error(f"Error getting files for entry {entry_id}: {e}")
raise ValueError(f"Error retrieving files: {str(e)}") raise ValueError(f"Error retrieving files: {str(e)}")
def filter_files_by_episode( def filter_files_by_episode(
self, files: List[Dict[str, Any]], target_episode: int self, files: List[Dict[str, Any]], target_episode: int
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Filter subtitle files to only include those matching the target episode. Filter subtitle files to only include ones matching the target episode.
Parameters Parameters
---------- ----------
@@ -442,41 +576,36 @@ class JimakuDownloader:
Returns Returns
------- -------
list list
Filtered list of file info dictionaries matching the target episode, Filtered list of file info dicts matching the target episode,
or all files if no matches are found or all files if no matches are found
""" """
filtered_files = [] specific_matches = []
# More flexible episode pattern that can detect various formats:
# - E01, e01, Ep01, EP01, episode01
# - Just the number: 01, 1
# - With separators: - 01, _01, .01
# Using word boundaries to avoid matching random numbers
episode_patterns = [ episode_patterns = [
# Standard episode markers
re_compile(r"[Ee](?:p(?:isode)?)?[ ._-]*(\d+)", IGNORECASE), re_compile(r"[Ee](?:p(?:isode)?)?[ ._-]*(\d+)", IGNORECASE),
# Just the number with word boundary or separator before it
re_compile(r"(?:^|\s|[._-])(\d+)(?:\s|$|[._-])", IGNORECASE), re_compile(r"(?:^|\s|[._-])(\d+)(?:\s|$|[._-])", IGNORECASE),
# Number with hash
re_compile(r"#(\d+)", IGNORECASE), re_compile(r"#(\d+)", IGNORECASE),
] ]
# Check for keywords that indicate a file covers all episodes
all_episodes_keywords = ["all", "batch", "complete", "season", "full"] all_episodes_keywords = ["all", "batch", "complete", "season", "full"]
batch_files = []
# First pass: find exact episode matches
for file_info in files: for file_info in files:
filename = file_info.get("name", "").lower() filename = file_info.get("name", "").lower()
matched = False matched = False
# Try each pattern to find episode number # Try to match specific episode numbers
for pattern in episode_patterns: for pattern in episode_patterns:
matches = pattern.findall(filename) matches = pattern.findall(filename)
for match in matches: for match in matches:
try: try:
file_episode = int(match) file_episode = int(match)
if file_episode == target_episode: if file_episode == target_episode:
filtered_files.append(file_info) specific_matches.append(file_info)
self.logger.debug( self.logger.debug(
f"Matched episode {target_episode} in: {filename}" "Matched episode %s in: %s",
target_episode,
filename,
) )
matched = True matched = True
break break
@@ -485,28 +614,30 @@ class JimakuDownloader:
if matched: if matched:
break break
# If we didn't find a match but it might be a batch file # Identify batch files
if not matched: if not matched:
# Check if it seems to be a batch file that would include our episode
might_include_episode = any( might_include_episode = any(
keyword in filename for keyword in all_episodes_keywords keyword in filename for keyword in all_episodes_keywords
) )
if might_include_episode: if might_include_episode:
self.logger.debug( self.logger.debug(f"Potential batch file: {filename}")
f"Might include episode {target_episode} (batch): {filename}" batch_files.append(file_info)
)
filtered_files.append(file_info) # Always include batch files, but sort them to the end
filtered_files = specific_matches + batch_files
if filtered_files: if filtered_files:
self.logger.info( total_specific = len(specific_matches)
f"Found {len(filtered_files)} files matching episode {target_episode}" total_batch = len(batch_files)
) msg = f"Found {len(filtered_files)} "
msg += f"matches for episode {target_episode} "
msg += f"({total_specific} specific matches, "
msg += f"{total_batch} batch files)"
self.logger.debug(msg)
return filtered_files return filtered_files
else: else:
# If no matches found, return all files to avoid empty selection
self.logger.warning( self.logger.warning(
f"No files specifically matched episode {target_episode}, showing all options" f"No files matched ep {target_episode}, showing all options"
) )
return files return files
@@ -527,7 +658,7 @@ class JimakuDownloader:
------- -------
str or list or None str or list or None
If multi=False: Selected option string or None if cancelled If multi=False: Selected option string or None if cancelled
If multi=True: List of selected option strings or empty list if cancelled If multi=True: List of selected option strings or empty list
""" """
try: try:
fzf_args = ["fzf", "--height=40%", "--border"] fzf_args = ["fzf", "--height=40%", "--border"]
@@ -593,21 +724,27 @@ class JimakuDownloader:
raise ValueError(f"Error downloading file: {str(e)}") raise ValueError(f"Error downloading file: {str(e)}")
def download_subtitles( def download_subtitles(
self, media_path: str, dest_dir: Optional[str] = None, play: bool = False self,
media_path: str,
dest_dir: Optional[str] = None,
play: bool = False,
anilist_id: Optional[int] = None,
) -> List[str]: ) -> List[str]:
""" """
Download subtitles for the given media path. Download subtitles for the given media path.
This is the main entry point method that orchestrates the entire download process. This is the main entry point for the entire download process.
Parameters Parameters
---------- ----------
media_path : str media_path : str
Path to the media file or directory Path to the media file or directory
dest_dir : str, optional dest_dir : str, optional
Directory to save downloaded subtitles (default: same directory as media) Directory to save subtitles (default: same directory as media)
play : bool, default=False play : bool, default=False
Whether to launch MPV with the subtitles after download Whether to launch MPV with the subtitles after download
anilist_id : int, optional
AniList ID to use directly instead of searching
Returns Returns
------- -------
@@ -624,13 +761,11 @@ class JimakuDownloader:
self.logger.info("Starting subtitle search and download process") self.logger.info("Starting subtitle search and download process")
# Check if input is a file or directory
is_directory = self.is_directory_input(media_path) is_directory = self.is_directory_input(media_path)
self.logger.info( msg = f"Processing {'directory' if is_directory else 'file'}: "
f"Processing {'directory' if is_directory else 'file'}: {media_path}" msg += f"{media_path}"
) self.logger.info(msg)
# Set destination directory
if dest_dir: if dest_dir:
dest_dir = dest_dir dest_dir = dest_dir
else: else:
@@ -641,13 +776,14 @@ class JimakuDownloader:
self.logger.debug(f"Destination directory: {dest_dir}") self.logger.debug(f"Destination directory: {dest_dir}")
# Parse media information based on input type
if is_directory: if is_directory:
title, season, episode = self.find_anime_title_in_path(media_path) title, season, episode = self.find_anime_title_in_path(media_path)
media_dir = media_path media_dir = media_path
media_file = None # No specific file for directory input media_file = None
self.logger.debug( self.logger.debug(
f"Found anime title '{title}' but will save subtitles to: {dest_dir}" "Found anime title '%s' but will save subtitles to: %s",
title,
dest_dir,
) )
else: else:
base_filename = basename(media_path) base_filename = basename(media_path)
@@ -659,35 +795,50 @@ class JimakuDownloader:
f"Identified show: {title}, Season: {season}, Episode: {episode}" f"Identified show: {title}, Season: {season}, Episode: {episode}"
) )
# Get AniList ID (either from cache or by querying) if anilist_id is None:
anilist_id = self.load_cached_anilist_id(media_dir) anilist_id = self.load_cached_anilist_id(media_dir)
if not anilist_id: if not anilist_id:
self.logger.info("Querying AniList for media ID...") self.logger.info("Querying AniList for media ID...")
anilist_id = self.query_anilist(title) anilist_id = self.query_anilist(title, season)
self.logger.info(f"AniList ID for '{title}' is {anilist_id}") self.logger.info(f"AniList ID for '{title}' is {anilist_id}")
self.save_anilist_id(media_dir, anilist_id) self.save_anilist_id(media_dir, anilist_id)
else: else:
self.logger.info(f"Using cached AniList ID: {anilist_id}") msg = f"Using {'provided' if anilist_id else 'cached'} "
msg += f"AniList ID: {anilist_id}"
self.logger.info(msg)
# Now check for API token before making Jimaku API calls
if not self.api_token:
self.logger.error(
"Jimaku API token is required to download subtitles. "
"Please set it with --token or the "
"JIMAKU_API_TOKEN environment variable."
)
raise ValueError(
"Jimaku API token is required to download subtitles. "
"Please set it with --token or the "
"JIMAKU_API_TOKEN environment variable."
)
# Query Jimaku for available subtitle entries
self.logger.info("Querying Jimaku for subtitle entries...") self.logger.info("Querying Jimaku for subtitle entries...")
entries = self.query_jimaku_entries(anilist_id) entries = self.query_jimaku_entries(anilist_id)
# Present entries in fzf for selection if not entries:
raise ValueError("No subtitle entries found for AniList ID")
entry_options = [] entry_options = []
entry_mapping = {} entry_mapping = {}
for entry in entries: for i, entry in enumerate(entries, start=1):
opt = f"{entry.get('name', 'No Title')} - {entry.get('flags', {}).get('japanese_name', 'Unknown')}" opt = f"{i}. {entry.get('english_name', 'No Eng Name')} - "
opt += f"{entry.get('japanese_name', 'None')}"
entry_options.append(opt) entry_options.append(opt)
entry_mapping[opt] = entry entry_mapping[opt] = entry
# Sort entry options alphabetically
entry_options.sort() entry_options.sort()
self.logger.info("Select a subtitle entry using fzf:") self.logger.info("Select a subtitle entry using fzf:")
selected_entry_option = self.fzf_menu( selected_entry_option = self.fzf_menu(entry_options, multi=False)
entry_options, multi=False
) # Always single selection for entries
if not selected_entry_option or selected_entry_option not in entry_mapping: if not selected_entry_option or selected_entry_option not in entry_mapping:
raise ValueError("No valid entry selected") raise ValueError("No valid entry selected")
@@ -696,45 +847,36 @@ class JimakuDownloader:
if not entry_id: if not entry_id:
raise ValueError("Selected entry does not have a valid ID") raise ValueError("Selected entry does not have a valid ID")
# Retrieve the files for the selected entry
self.logger.info(f"Retrieving files for entry ID: {entry_id}") self.logger.info(f"Retrieving files for entry ID: {entry_id}")
files = self.get_entry_files(entry_id) files = self.get_entry_files(entry_id)
# For file input: filter files by episode
if not is_directory and episode > 0: if not is_directory and episode > 0:
self.logger.info(f"Filtering subtitle files for episode {episode}") self.logger.info(f"Filtering subtitle files for episode {episode}")
files = self.filter_files_by_episode(files, episode) files = self.filter_files_by_episode(files, episode)
# Present available subtitle files for selection
file_options = [] file_options = []
file_mapping = {} file_mapping = {}
for file_info in files: for i, file_info in enumerate(files, start=1):
display = f"{file_info.get('name', 'Unknown')}" display = f"{i}. {file_info.get('name', 'Unknown')}"
file_options.append(display) file_options.append(display)
file_mapping[display] = file_info file_mapping[display] = file_info
# Sort the file options alphabetically for better readability
file_options.sort() file_options.sort()
# Use multi-select mode only for directory input
self.logger.info( self.logger.info(
f"Select {'one or more' if is_directory else 'one'} subtitle file(s):" f"Select {'one or more' if is_directory else 'one'} " "subtitle file(s):"
) )
selected_files = self.fzf_menu(file_options, multi=is_directory) selected_files = self.fzf_menu(file_options, multi=is_directory)
# Handle the different return types based on multi or single selection if is_directory:
if is_directory: # multi-select mode
if not selected_files: if not selected_files:
raise ValueError("No subtitle files selected") raise ValueError("No subtitle files selected")
selected_files_list = selected_files # already a list selected_files_list = selected_files
else: # single-select mode else:
if not selected_files: if not selected_files:
raise ValueError("No subtitle file selected") raise ValueError("No subtitle file selected")
selected_files_list = [ selected_files_list = [selected_files]
selected_files
] # convert to list for consistent processing
# Download each selected subtitle file
downloaded_files = [] downloaded_files = []
for opt in selected_files_list: for opt in selected_files_list:
file_info = file_mapping.get(opt) file_info = file_mapping.get(opt)
@@ -745,15 +887,13 @@ class JimakuDownloader:
download_url = file_info.get("url") download_url = file_info.get("url")
if not download_url: if not download_url:
self.logger.warning( self.logger.warning(
f"File option '{opt}' does not have a download URL. Skipping." f"File option '{opt}' does not have a download URL. " "Skipping."
) )
continue continue
# Use provided filename if available; otherwise, default to base video name + suffix. filename = file_info.get("name")
filename = file_info.get("filename")
if not filename: if not filename:
if is_directory: if is_directory:
# For directory input, use the file's own name or ID
filename = f"{file_info.get('name', 'subtitle.srt')}" filename = f"{file_info.get('name', 'subtitle.srt')}"
dest_path = join(dest_dir, filename) dest_path = join(dest_dir, filename)
@@ -762,22 +902,22 @@ class JimakuDownloader:
downloaded_files.append(dest_path) downloaded_files.append(dest_path)
self.logger.info(f"Subtitle saved to: {dest_path}") self.logger.info(f"Subtitle saved to: {dest_path}")
# Optionally, launch MPV with the video file and the downloaded subtitles
if play and not is_directory: if play and not is_directory:
self.logger.info("Launching MPV with the subtitle files...") self.logger.info("Launching MPV with the subtitle files...")
mpv_cmd = ["mpv", media_file] mpv_cmd = ["mpv", media_file]
for sub in downloaded_files: mpv_cmd.extend([f"--sub-file={filename}"])
mpv_cmd.extend(["--sub-file", sub])
try: try:
self.logger.debug(f"Running command: {' '.join(mpv_cmd)}") self.logger.debug(f"Running command: {' '.join(mpv_cmd)}")
subprocess_run(mpv_cmd) subprocess_run(mpv_cmd)
except FileNotFoundError: except FileNotFoundError:
self.logger.error( self.logger.error(
"MPV not found. Please install MPV and ensure it is in your PATH." "MPV not found. "
"Please install MPV and ensure it is in your PATH."
) )
elif play and is_directory: elif play and is_directory:
self.logger.warning( self.logger.warning(
"Cannot play media with MPV when input is a directory. Skipping playback." "Cannot play media with MPV when input is a directory. "
"Skipping playback."
) )
self.logger.info("Subtitle download process completed successfully") self.logger.info("Subtitle download process completed successfully")

50
tests/README.md Normal file
View File

@@ -0,0 +1,50 @@
# Jimaku-DL Tests
This directory contains tests for the jimaku-dl package using pytest.
## Running Tests
To run all tests:
```bash
pytest
```
To run with verbose output:
```bash
pytest -v
```
To run a specific test file:
```bash
pytest tests/test_downloader.py
```
To run a specific test:
```bash
pytest tests/test_downloader.py::TestJimakuDownloader::test_init
```
## Test Coverage
To generate a test coverage report:
```bash
pytest --cov=jimaku_dl
```
For an HTML coverage report:
```bash
pytest --cov=jimaku_dl --cov-report=html
```
## Adding Tests
1. Create test files with the naming convention `test_*.py`
2. Create test classes with the naming convention `Test*`
3. Create test methods with the naming convention `test_*`
4. Use the fixtures defined in `conftest.py` for common functionality

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test package for jimaku-dl."""

147
tests/conftest.py Normal file
View File

@@ -0,0 +1,147 @@
"""Global pytest fixtures for jimaku-dl tests."""
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add the src directory to the Python path
project_root = Path(__file__).parent.parent
src_path = project_root / "src"
sys.path.insert(0, str(src_path))
@pytest.fixture
def temp_dir():
"""Create a temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmpdirname:
yield tmpdirname
@pytest.fixture
def mock_anilist_response():
"""Mock response from AniList API."""
return {
"data": {
"Media": {
"id": 123456,
"title": {
"romaji": "Test Anime",
"english": "Test Anime English",
"native": "テストアニメ",
},
"synonyms": ["Test Show"],
}
}
}
@pytest.fixture
def mock_jimaku_entries_response():
"""Mock response from Jimaku entries endpoint."""
return [
{
"id": 1,
"english_name": "Test Anime",
"japanese_name": "テストアニメ",
"anilist_id": 123456,
}
]
@pytest.fixture
def mock_jimaku_files_response():
"""Mock response from Jimaku files endpoint."""
return [
{
"id": 101,
"name": "Test Anime - 01.srt",
"url": "https://jimaku.cc/api/files/101",
},
{
"id": 102,
"name": "Test Anime - 02.srt",
"url": "https://jimaku.cc/api/files/102",
},
]
@pytest.fixture
def mock_requests(
monkeypatch,
mock_anilist_response,
mock_jimaku_entries_response,
mock_jimaku_files_response,
):
"""Mock requests module for API calls."""
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = MagicMock()
def mock_requests_post(url, **kwargs):
if "anilist.co" in url:
mock_response.json.return_value = mock_anilist_response
return mock_response
def mock_requests_get(url, **kwargs):
if "entries/search" in url:
mock_response.json.return_value = mock_jimaku_entries_response
elif "entries/" in url and "/files" in url:
mock_response.json.return_value = mock_jimaku_files_response
return mock_response
# Patch both the direct imports used in downloader.py and the regular requests module
monkeypatch.setattr("requests.post", mock_requests_post)
monkeypatch.setattr("requests.get", mock_requests_get)
monkeypatch.setattr("jimaku_dl.downloader.requests_post", mock_requests_post)
monkeypatch.setattr("jimaku_dl.downloader.requests_get", mock_requests_get)
return {
"post": mock_requests_post,
"get": mock_requests_get,
"response": mock_response,
}
@pytest.fixture
def mock_subprocess(monkeypatch):
"""Mock subprocess module for fzf and mpv calls."""
mock_run = MagicMock()
mock_result = MagicMock()
mock_result.stdout = "1. Test Selection"
mock_run.return_value = mock_result
monkeypatch.setattr("subprocess.run", mock_run)
return mock_run
@pytest.fixture
def sample_video_file(temp_dir):
"""Create a sample video file."""
file_path = os.path.join(temp_dir, "Test Anime S01E01 [1080p].mkv")
with open(file_path, "wb") as f:
f.write(b"dummy video content")
return file_path
@pytest.fixture
def sample_anime_directory(temp_dir):
"""Create a sample directory structure for anime."""
# Main directory
anime_dir = os.path.join(temp_dir, "Test Anime")
os.makedirs(anime_dir)
# Season subdirectory
season_dir = os.path.join(anime_dir, "Season-1")
os.makedirs(season_dir)
# Episode files
for i in range(1, 3):
file_path = os.path.join(season_dir, f"Test Anime S01E0{i} [1080p].mkv")
with open(file_path, "wb") as f:
f.write(b"dummy video content")
return anime_dir

5
tests/coverage.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
coverage run -m pytest
coverage html
coverage report -m

2
tests/fixtures/__init__.py vendored Normal file
View File

@@ -0,0 +1,2 @@
"""Test fixtures package."""
# This file can be empty, it's just to make the directory a proper package

318
tests/test_cli.py Normal file
View File

@@ -0,0 +1,318 @@
"""Tests for the command line interface module."""
import sys
from unittest.mock import MagicMock, patch
import pytest
from jimaku_dl.cli import __version__, main
class TestCli:
"""Tests for the command line interface."""
def test_main_success(self, monkeypatch):
"""Test successful execution of the CLI main function."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.return_value = [
"/path/to/subtitle.srt"
]
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch("sys.argv", ["jimaku-dl", "/path/to/video.mkv"]):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = False
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = None
result = main()
assert result == 0
mock_downloader.assert_called_once_with(
api_token="test_token", log_level="INFO"
)
mock_downloader.return_value.download_subtitles.assert_called_once_with(
media_path="/path/to/video.mkv",
dest_dir=None,
play=False,
anilist_id=None,
)
def test_main_error(self, monkeypatch):
"""Test CLI error handling."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.side_effect = ValueError(
"Test error"
)
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch("sys.argv", ["jimaku-dl", "/path/to/video.mkv"]):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = False
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = None
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
mock_print.assert_called_with("Error: Test error")
def test_main_unexpected_error(self, monkeypatch):
"""Test CLI handling of unexpected errors."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.side_effect = Exception(
"Unexpected error"
)
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch("sys.argv", ["jimaku-dl", "/path/to/video.mkv"]):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = False
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = None
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
mock_print.assert_called_with("Unexpected error: Unexpected error")
def test_anilist_id_arg(self, monkeypatch):
"""Test CLI with anilist_id argument."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.return_value = [
"/path/to/subtitle.srt"
]
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch(
"sys.argv", ["jimaku-dl", "/path/to/video.mkv", "--anilist-id", "123456"]
):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = False
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = 123456
result = main()
assert result == 0
mock_downloader.return_value.download_subtitles.assert_called_once_with(
media_path="/path/to/video.mkv",
dest_dir=None,
play=False,
anilist_id=123456,
)
def test_dest_arg(self, monkeypatch):
"""Test CLI with dest argument."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.return_value = [
"/custom/path/subtitle.srt"
]
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch(
"sys.argv", ["jimaku-dl", "/path/to/video.mkv", "--dest", "/custom/path"]
):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = "/custom/path"
mock_args.return_value.play = False
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = None
result = main()
assert result == 0
mock_downloader.return_value.download_subtitles.assert_called_once_with(
media_path="/path/to/video.mkv",
dest_dir="/custom/path",
play=False,
anilist_id=None,
)
def test_play_arg(self, monkeypatch):
"""Test CLI with play argument."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.return_value = [
"/path/to/subtitle.srt"
]
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch("sys.argv", ["jimaku-dl", "/path/to/video.mkv", "--play"]):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = True
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = None
result = main()
assert result == 0
mock_downloader.return_value.download_subtitles.assert_called_once_with(
media_path="/path/to/video.mkv",
dest_dir=None,
play=True,
anilist_id=None,
)
def test_token_arg(self, monkeypatch):
"""Test CLI with token argument."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.return_value = [
"/path/to/subtitle.srt"
]
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch(
"sys.argv", ["jimaku-dl", "/path/to/video.mkv", "--token", "custom_token"]
):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = False
mock_args.return_value.api_token = "custom_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = None
result = main()
assert result == 0
mock_downloader.assert_called_once_with(
api_token="custom_token", log_level="INFO"
)
def test_log_level_arg(self, monkeypatch):
"""Test CLI with log_level argument."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.return_value = [
"/path/to/subtitle.srt"
]
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch(
"sys.argv", ["jimaku-dl", "/path/to/video.mkv", "--log-level", "DEBUG"]
):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = False
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "DEBUG"
mock_args.return_value.anilist_id = None
result = main()
assert result == 0
mock_downloader.assert_called_once_with(
api_token="test_token", log_level="DEBUG"
)
def test_version_arg(self, capsys, monkeypatch):
"""Test CLI with version argument."""
with patch("sys.argv", ["jimaku-dl", "--version"]):
with pytest.raises(SystemExit) as excinfo:
main()
assert excinfo.value.code == 0
# Check that version is printed
captured = capsys.readouterr()
assert f"jimaku-dl {__version__}" in captured.out
def test_help_arg(self, capsys, monkeypatch):
"""Test CLI with help argument."""
with patch("sys.argv", ["jimaku-dl", "--help"]):
with pytest.raises(SystemExit) as excinfo:
main()
assert excinfo.value.code == 0
# Help text is printed to stdout by argparse
captured = capsys.readouterr()
assert "usage:" in captured.out
def test_keyboard_interrupt(self, monkeypatch):
"""Test handling of keyboard interrupt."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.side_effect = (
KeyboardInterrupt()
)
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch("sys.argv", ["jimaku-dl", "/path/to/video.mkv"]):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = None
mock_args.return_value.play = False
mock_args.return_value.api_token = "test_token"
mock_args.return_value.log_level = "INFO"
mock_args.return_value.anilist_id = None
with patch("builtins.print") as mock_print:
result = main()
assert result == 1
mock_print.assert_called_with("\nOperation cancelled by user.")
def test_short_options(self, monkeypatch):
"""Test CLI with short options instead of long options."""
mock_downloader = MagicMock()
mock_downloader.return_value.download_subtitles.return_value = [
"/path/to/subtitle.srt"
]
monkeypatch.setattr("jimaku_dl.cli.JimakuDownloader", mock_downloader)
with patch(
"sys.argv",
[
"jimaku-dl",
"/path/to/video.mkv",
"-d",
"/custom/path",
"-p",
"-t",
"short_token",
"-l",
"DEBUG",
"-a",
"789",
],
):
with patch("jimaku_dl.cli.ArgumentParser.parse_args") as mock_args:
mock_args.return_value.media_path = "/path/to/video.mkv"
mock_args.return_value.dest = "/custom/path"
mock_args.return_value.play = True
mock_args.return_value.api_token = "short_token"
mock_args.return_value.log_level = "DEBUG"
mock_args.return_value.anilist_id = 789
result = main()
assert result == 0
mock_downloader.assert_called_once_with(
api_token="short_token", log_level="DEBUG"
)
mock_downloader.return_value.download_subtitles.assert_called_once_with(
media_path="/path/to/video.mkv",
dest_dir="/custom/path",
play=True,
anilist_id=789,
)

1002
tests/test_downloader.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
"""Tests specifically for the filter_files_by_episode method."""
import pytest
from jimaku_dl.downloader import JimakuDownloader
class TestFilterFilesByEpisode:
"""Test suite for filter_files_by_episode method."""
def setup_method(self):
"""Set up test method with a fresh downloader instance."""
self.downloader = JimakuDownloader(api_token="test_token")
# Setup common test files
self.all_files = [
{"id": 1, "name": "Show - 01.srt"},
{"id": 2, "name": "Show - 02.srt"},
{"id": 3, "name": "Show - 03.srt"},
{"id": 4, "name": "Show - E04.srt"},
{"id": 5, "name": "Show Episode 05.srt"},
{"id": 6, "name": "Show #06.srt"},
{"id": 7, "name": "Show.S01E07.srt"},
{"id": 8, "name": "Show - BATCH.srt"},
{"id": 9, "name": "Show - Complete.srt"},
{"id": 10, "name": "Show - All Episodes.srt"},
]
def test_exact_episode_matches(self):
"""Test finding exact episode matches with different filename patterns."""
# Test standard episode format
filtered = self.downloader.filter_files_by_episode(self.all_files, 1)
assert len(filtered) == 4 # 1 specific match + 3 batch files
assert filtered[0]["name"] == "Show - 01.srt" # Specific match should be first
# Test E## format
filtered = self.downloader.filter_files_by_episode(self.all_files, 4)
assert len(filtered) == 4 # 1 specific match + 3 batch files
assert filtered[0]["name"] == "Show - E04.srt" # Specific match should be first
# Test 'Episode ##' format
filtered = self.downloader.filter_files_by_episode(self.all_files, 5)
assert len(filtered) == 4 # 1 specific match + 3 batch files
assert (
filtered[0]["name"] == "Show Episode 05.srt"
) # Specific match should be first
# Test '#' format
filtered = self.downloader.filter_files_by_episode(self.all_files, 6)
assert len(filtered) == 4 # 1 specific match + 3 batch files
assert filtered[0]["name"] == "Show #06.srt" # Specific match should be first
# Test S##E## format
filtered = self.downloader.filter_files_by_episode(self.all_files, 7)
assert len(filtered) == 4 # 1 specific match + 3 batch files
assert (
filtered[0]["name"] == "Show.S01E07.srt"
) # Specific match should be first
def test_batch_files_inclusion(self):
"""Test that batch files are always included but sorted after specific matches."""
# For all episodes, batch files should be included now
filtered = self.downloader.filter_files_by_episode(self.all_files, 1)
assert len(filtered) == 4 # 1 specific + 3 batch
assert any("BATCH" in f["name"] for f in filtered)
assert any("Complete" in f["name"] for f in filtered)
assert any("All Episodes" in f["name"] for f in filtered)
# Specific match should be first, followed by batch files
assert filtered[0]["name"] == "Show - 01.srt"
assert all(
keyword in f["name"]
for f, keyword in zip(filtered[1:], ["BATCH", "Complete", "All Episodes"])
)
# Same for episode 3
filtered = self.downloader.filter_files_by_episode(self.all_files, 3)
assert len(filtered) == 4 # 1 specific + 3 batch
assert filtered[0]["name"] == "Show - 03.srt"
assert all(
keyword in " ".join([f["name"] for f in filtered[1:]])
for keyword in ["BATCH", "Complete", "All Episodes"]
)
# For high episode numbers with no match, only batch files should be returned
filtered = self.downloader.filter_files_by_episode(self.all_files, 10)
assert len(filtered) == 3
assert all(
f["name"]
in ["Show - BATCH.srt", "Show - Complete.srt", "Show - All Episodes.srt"]
for f in filtered
)
def test_no_episode_matches(self):
"""Test behavior when no episodes match."""
# For non-existent episodes, should return batch files
filtered = self.downloader.filter_files_by_episode(self.all_files, 99)
assert len(filtered) == 3
assert all(
f["name"]
in ["Show - BATCH.srt", "Show - Complete.srt", "Show - All Episodes.srt"]
for f in filtered
)
# For a list with no batch files and no matches, should return all files
no_batch_files = [
f
for f in self.all_files
if not any(
keyword in f["name"].lower()
for keyword in ["batch", "complete", "all", "season"]
)
]
filtered = self.downloader.filter_files_by_episode(no_batch_files, 99)
assert filtered == no_batch_files
def test_ordering_of_results(self):
"""Test that specific episode matches are always before batch files."""
# Create a reversed test set to ensure sorting works
reversed_files = list(reversed(self.all_files))
# Test with episode that has a specific match
filtered = self.downloader.filter_files_by_episode(reversed_files, 4)
# Verify specific match is first
assert filtered[0]["name"] == "Show - E04.srt"
# Verify batch files follow
for f in filtered[1:]:
assert any(
keyword in f["name"].lower()
for keyword in ["batch", "complete", "all episodes"]
)
def test_edge_case_episode_formats(self):
"""Test edge case episode number formats."""
# Create test files with unusual formats
edge_case_files = [
{"id": 1, "name": "Show - ep.01.srt"}, # With period
{"id": 2, "name": "Show - ep01v2.srt"}, # With version
{"id": 3, "name": "Show - e.03.srt"}, # Abbreviated with period
{"id": 4, "name": "Show - episode.04.srt"}, # Full word with period
{"id": 5, "name": "Show - 05.v2.srt"}, # Version format
{"id": 6, "name": "Show - [06].srt"}, # Bracketed number
]
# Test detection of 01 in filenames
filtered = self.downloader.filter_files_by_episode(edge_case_files, 1)
# In the current implementation, these might all be included since regex matching is imperfect
# So we just check that the correct ones are present and first
assert any(f["name"] == "Show - ep.01.srt" for f in filtered)
assert any(f["name"] == "Show - ep01v2.srt" for f in filtered)
# Test detection of episode.04
filtered = self.downloader.filter_files_by_episode(edge_case_files, 4)
assert any(f["name"] == "Show - episode.04.srt" for f in filtered)
# Test detection of [06]
filtered = self.downloader.filter_files_by_episode(edge_case_files, 6)
assert any(f["name"] == "Show - [06].srt" for f in filtered)
# Test episode that doesn't exist
filtered = self.downloader.filter_files_by_episode(edge_case_files, 99)
# Should return all files when no batch files and no matches
assert len(filtered) == len(edge_case_files)
def test_duplicate_episode_matches(self):
"""Test handling of duplicate episode matches in filenames."""
# Files with multiple episode numbers in the name
dup_files = [
{"id": 1, "name": "Show - 01 - Episode 1.srt"}, # Same number twice
{"id": 2, "name": "Show 02 - Ep02.srt"}, # Same number twice
{"id": 3, "name": "Show - 03 - 04.srt"}, # Different numbers
{"id": 4, "name": "Show - Ep05 Extra 06.srt"}, # Different numbers
]
# Should match the first number for episode 1
filtered = self.downloader.filter_files_by_episode(dup_files, 1)
assert len(filtered) == 1
assert filtered[0]["name"] == "Show - 01 - Episode 1.srt"
# Should match both formats for episode 2
filtered = self.downloader.filter_files_by_episode(dup_files, 2)
assert len(filtered) == 1
assert filtered[0]["name"] == "Show 02 - Ep02.srt"
# Should match the first number for episode 3
filtered = self.downloader.filter_files_by_episode(dup_files, 3)
assert len(filtered) == 1
assert filtered[0]["name"] == "Show - 03 - 04.srt"
# Should match the second number for episode 4
filtered = self.downloader.filter_files_by_episode(dup_files, 4)
assert len(filtered) == 1
assert filtered[0]["name"] == "Show - 03 - 04.srt"
def test_empty_file_list(self):
"""Test behavior with empty file list."""
filtered = self.downloader.filter_files_by_episode([], 1)
assert filtered == []

View File

@@ -0,0 +1,126 @@
"""Tests specifically for the parse_directory_name method."""
import pytest
from jimaku_dl.downloader import JimakuDownloader
class TestParseDirectoryName:
"""Test suite for parse_directory_name method."""
def setup_method(self):
"""Set up test method with a fresh downloader instance."""
self.downloader = JimakuDownloader(api_token="test_token")
def test_basic_directory_names(self):
"""Test basic directory name parsing."""
# Standard name
success, title, season, episode = self.downloader.parse_directory_name(
"/path/to/My Anime Show"
)
assert success is True
assert title == "My Anime Show"
assert season == 1
assert episode == 0
# Name with underscores
success, title, season, episode = self.downloader.parse_directory_name(
"/path/to/My_Anime_Show"
)
assert success is True
assert title == "My Anime Show" # Underscores should be converted to spaces
assert season == 1
assert episode == 0
# Name with dots
success, title, season, episode = self.downloader.parse_directory_name(
"/path/to/My.Anime.Show"
)
assert success is True
assert title == "My Anime Show" # Dots should be converted to spaces
assert season == 1
assert episode == 0
def test_common_system_directories(self):
"""Test handling of common system directories that should be rejected."""
# Common system directories
for sys_dir in [
"bin",
"etc",
"lib",
"home",
"usr",
"var",
"tmp",
"opt",
"media",
"mnt",
]:
success, _, _, _ = self.downloader.parse_directory_name(
f"/path/to/{sys_dir}"
)
assert success is False, f"Directory '{sys_dir}' should be rejected"
# Root directory
success, _, _, _ = self.downloader.parse_directory_name("/")
assert success is False
# Current directory
success, _, _, _ = self.downloader.parse_directory_name(".")
assert success is False
# Parent directory
success, _, _, _ = self.downloader.parse_directory_name("..")
assert success is False
def test_short_directory_names(self):
"""Test handling of directory names that are too short."""
# One-character name
success, _, _, _ = self.downloader.parse_directory_name("/path/to/A")
assert success is False
# Two-character name
success, _, _, _ = self.downloader.parse_directory_name("/path/to/AB")
assert success is False
# Three-character name (should be accepted)
success, title, _, _ = self.downloader.parse_directory_name("/path/to/ABC")
assert success is True
assert title == "ABC"
def test_special_characters(self):
"""Test directories with special characters."""
# Directory with parentheses
success, title, _, _ = self.downloader.parse_directory_name(
"/path/to/My Anime (2022)"
)
assert success is True
assert title == "My Anime (2022)"
# Directory with brackets
success, title, _, _ = self.downloader.parse_directory_name(
"/path/to/My Anime [Uncensored]"
)
assert success is True
assert title == "My Anime [Uncensored]"
# Directory with other special characters
success, title, _, _ = self.downloader.parse_directory_name(
"/path/to/My Anime: The Movie - Part 2!"
)
assert success is True
assert title == "My Anime: The Movie - Part 2!"
def test_directory_with_season_info(self):
"""Test directories with season information."""
# Directory with season in name
success, title, _, _ = self.downloader.parse_directory_name(
"/path/to/Anime Season 2"
)
assert success is True
assert title == "Anime Season 2"
# Directory that only specifies season
success, title, _, _ = self.downloader.parse_directory_name("/path/to/Season 3")
assert success is True
assert title == "Season 3"

View File

@@ -0,0 +1,264 @@
"""Tests specifically for the parse_filename method."""
from unittest.mock import patch
import pytest
from jimaku_dl.downloader import JimakuDownloader
class TestParseFilename:
"""Test suite for parse_filename method."""
def setup_method(self):
"""Set up test method with a fresh downloader instance."""
self.downloader = JimakuDownloader(api_token="test_token")
def test_trash_guides_format(self):
"""Test parsing filenames that follow Trash Guides naming convention."""
# Basic Trash Guides format
title, season, episode = self.downloader.parse_filename(
"Show Title - S01E02 - Episode Name [1080p]"
)
assert title == "Show Title"
assert season == 1
assert episode == 2
# With year included
title, season, episode = self.downloader.parse_filename(
"Show Title (2020) - S03E04 - Episode Name [1080p]"
)
assert title == "Show Title"
assert season == 3
assert episode == 4
# More complex example
title, season, episode = self.downloader.parse_filename(
"My Favorite Anime (2023) - S02E05 - The Big Battle [1080p][10bit][h265][Dual-Audio]"
)
assert title == "My Favorite Anime"
assert season == 2
assert episode == 5
def test_standard_formats(self):
"""Test parsing standard filename formats."""
# S01E01 format
title, season, episode = self.downloader.parse_filename(
"Show.Name.S01E02.1080p.mkv"
)
assert title == "Show Name"
assert season == 1
assert episode == 2
# Separated by dots
title, season, episode = self.downloader.parse_filename(
"Show.Name.S03E04.x264.mkv"
)
assert title == "Show Name"
assert season == 3
assert episode == 4
# Separated by underscores
title, season, episode = self.downloader.parse_filename(
"Show_Name_S05E06_HEVC.mkv"
)
assert title == "Show Name"
assert season == 5
assert episode == 6
def test_directory_structure_extraction(self):
"""Test extracting info from directory structure."""
# Mock _prompt_for_title_info to avoid reading from stdin for the entire test function
with patch.object(self.downloader, "_prompt_for_title_info") as mock_prompt:
# Configure mock to return appropriate values for different test cases
mock_prompt.side_effect = [
("Show Name", 1, 2), # First call return value
("Show Name", 3, 4), # Second call return value
("My Anime", 2, 5), # Third call return value
("Long Anime Title With Spaces", 1, 3), # Fourth call return value
]
# Standard Season-## format
title, season, episode = self.downloader.parse_filename(
"/path/to/Show Name/Season-1/Show Name - 02 [1080p].mkv"
)
assert title == "Show Name"
assert season == 1
assert episode == 2
# Season ## format
title, season, episode = self.downloader.parse_filename(
"/path/to/Show Name/Season 03/Episode 4.mkv"
)
assert title == "Show Name"
assert season == 3
assert episode == 4
# Simple number in season directory
title, season, episode = self.downloader.parse_filename(
"/path/to/My Anime/Season 2/5.mkv"
)
assert title == "My Anime"
assert season == 2
assert episode == 5
# Long pathname with complex directory structure
title, season, episode = self.downloader.parse_filename(
"/media/user/Anime/Long Anime Title With Spaces/Season-1/Long Anime Title With Spaces - 03.mkv"
)
assert title == "Long Anime Title With Spaces"
assert season == 1
assert episode == 3
def test_complex_titles(self):
"""Test parsing filenames with complex titles."""
# Since we now prompt for non-standard formats, we need to mock the input
with patch.object(self.downloader, "_prompt_for_title_info") as mock_prompt:
# Set up the return values for the mock
mock_prompt.return_value = (
"Trapped in a Dating Sim - The World of Otome Games Is Tough for Mobs",
1,
11,
)
title, season, episode = self.downloader.parse_filename(
"Trapped in a Dating Sim - The World of Otome Games Is Tough for Mobs - S01E11.mkv"
)
assert (
title
== "Trapped in a Dating Sim - The World of Otome Games Is Tough for Mobs"
)
assert season == 1
assert episode == 11
# Reset the mock for the next call
mock_prompt.reset_mock()
mock_prompt.return_value = ("Re:Zero kara Hajimeru Isekai Seikatsu", 1, 15)
# Titles with special characters and patterns
title, season, episode = self.downloader.parse_filename(
"Re:Zero kara Hajimeru Isekai Seikatsu S01E15 [1080p].mkv"
)
assert title == "Re:Zero kara Hajimeru Isekai Seikatsu"
assert season == 1
assert episode == 15
def test_fallback_title_extraction(self):
"""Test fallback to user input for non-standard formats."""
with patch.object(self.downloader, "_prompt_for_title_info") as mock_prompt:
# Set up the mock to return specific values
mock_prompt.return_value = ("My Show", 1, 5)
# With various tags
title, season, episode = self.downloader.parse_filename(
"My Show [1080p] [HEVC] [10bit] [Dual-Audio] - 05.mkv"
)
assert title == "My Show"
assert season == 1
assert episode == 5
mock_prompt.assert_called_once()
# Reset mock for next test
mock_prompt.reset_mock()
mock_prompt.return_value = ("Great Anime", 1, 3)
# With episode at the end
title, season, episode = self.downloader.parse_filename(
"Great Anime 1080p BluRay x264 - 03.mkv"
)
assert title == "Great Anime"
assert season == 1
assert episode == 3
mock_prompt.assert_called_once()
def test_unparsable_filenames(self):
"""Test handling of filenames that can't be parsed."""
with patch.object(self.downloader, "_prompt_for_title_info") as mock_prompt:
mock_prompt.return_value = ("Manual Title", 2, 3)
title, season, episode = self.downloader.parse_filename("randomstring.mkv")
assert title == "Manual Title"
assert season == 2
assert episode == 3
mock_prompt.assert_called_once_with("randomstring.mkv")
# Test with completely random string
mock_prompt.reset_mock()
mock_prompt.return_value = ("Another Title", 4, 5)
title, season, episode = self.downloader.parse_filename("abc123xyz.mkv")
assert title == "Another Title"
assert season == 4
assert episode == 5
mock_prompt.assert_called_once_with("abc123xyz.mkv")
def test_unicode_filenames(self):
"""Test parsing filenames with unicode characters."""
# Testing with both Japanese title formats
# Standard format with Japanese title - parser can handle this without prompting
title, season, episode = self.downloader.parse_filename(
"この素晴らしい世界に祝福を! S01E03 [1080p].mkv"
)
assert title == "この素晴らしい世界に祝福を!"
assert season == 1
assert episode == 3
# For complex cases that might require prompting, use the mock
with patch.object(self.downloader, "_prompt_for_title_info") as mock_prompt:
# Mock the prompt for a case where the parser likely can't determine the structure
mock_prompt.return_value = ("この素晴らしい世界に祝福を!", 2, 4)
# Non-standard format with Japanese title
title, season, episode = self.downloader.parse_filename(
"この素晴らしい世界に祝福を! #04 [BD 1080p].mkv"
)
# Either the parser handles it or falls back to prompting
# We're mainly checking that the result is correct
assert title == "この素晴らしい世界に祝福を!"
# Season might be detected as 1 from parser or 2 from mock
# Episode might be detected as 4 from parser or from mock
assert episode == 4
# We don't assert on whether mock_prompt was called since that
# depends on implementation details of the parser
def test_unusual_formats(self):
"""Test handling of unusual filename formats."""
with patch.object(self.downloader, "_prompt_for_title_info") as mock_prompt:
# Reset after each test to check if prompt was called
mock_prompt.reset_mock()
mock_prompt.return_value = ("Show Title", 2, 5)
# Double episode format
title, season, episode = self.downloader.parse_filename(
"Show.Title.S02E05E06.1080p.mkv"
)
# Should extract the first episode number
assert title == "Show Title"
assert season == 2
assert episode == 5
mock_prompt.assert_not_called()
# Episode with zero padding
mock_prompt.reset_mock()
title, season, episode = self.downloader.parse_filename(
"Show Name - S03E009 - Episode Title.mkv"
)
assert title == "Show Name"
assert season == 3
assert episode == 9
mock_prompt.assert_not_called()
# Episode with decimal point
mock_prompt.reset_mock()
mock_prompt.return_value = ("Show Name", 1, 5)
title, season, episode = self.downloader.parse_filename(
"Show Name - 5.5 - Special Episode.mkv"
)
# This will likely prompt due to unusual format
assert title == "Show Name"
assert season == 1
assert episode == 5
mock_prompt.assert_called_once()