Skip to content
Merged

Dev #110

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ on:
description: "Image tag to push (e.g. 0.1.0-beta1)"
required: true
type: string
ref:
description: "Branch or commit to build from (e.g. dev)"
required: false
default: dev
type: string

concurrency:
group: docker-${{ github.ref }}
Expand All @@ -30,6 +35,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0-beta.13] - 2026-04-25

### Fixed

- Migration issue that could happen when jumping several version at once

## [0.1.0-beta.12] - 2026-04-24

### Added
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,20 @@ I needed a solution to manage a single physical media library while utilizing bo

Driven by the increasing cost of storage and the need to reclaim used disk space, inspired by Maintainerr, **Reclaimerr** was born.

## Star History

<a href="https://www.star-history.com/?repos=jessielw%2FReclaimerr&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=jessielw/Reclaimerr&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=jessielw/Reclaimerr&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=jessielw/Reclaimerr&type=date&legend=top-left" />
</picture>
</a>

# Discussions

While I prefer we utilize github's [discussions](https://github.com/jessielw/Reclaimerr/discussions) for historical purposes, I've had quite a few users ask for _Discord_. I personally am getting away from Discord ASAP, but I did create a public matrix server for discussions and some _support_. Feel free to join https://matrix.to/#/#reclaimerr:matrix.org!


# AI Disclosures

AI (LLMs) are _everywhere_ and _everyone_ is using them. I understand that many users are concerned about projects heavily generated by AI or lacking a personal touch. Reclaimerr was built from the ground up and was **not** generated using LLMs or a fork of any other project.
Expand Down
128 changes: 104 additions & 24 deletions backend/alembic/versions/1b25d7fd62d3_nullable_year.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,115 @@
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '1b25d7fd62d3'
down_revision: Union[str, None] = 'e85082c4be59'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _movies_table(year_nullable: bool) -> sa.Table:
return sa.Table(
'movies', sa.MetaData(),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('title', sa.String(length=512), nullable=False),
sa.Column('tmdb_id', sa.Integer(), nullable=False),
sa.Column('year', sa.SmallInteger(), nullable=year_nullable),
sa.Column('size', sa.Integer(), nullable=True),
sa.Column('radarr_id', sa.Integer(), nullable=True),
sa.Column('imdb_id', sa.String(length=20), nullable=True),
sa.Column('tmdb_title', sa.String(length=512), nullable=True),
sa.Column('original_title', sa.String(length=512), nullable=True),
sa.Column('tmdb_release_date', sa.DateTime(), nullable=True),
sa.Column('original_language', sa.String(length=10), nullable=True),
sa.Column('homepage', sa.String(length=500), nullable=True),
sa.Column('origin_country', sa.JSON(), nullable=True),
sa.Column('poster_url', sa.String(length=500), nullable=True),
sa.Column('backdrop_url', sa.String(length=500), nullable=True),
sa.Column('overview', sa.Text(), nullable=True),
sa.Column('genres', sa.JSON(), nullable=True),
sa.Column('popularity', sa.Float(), nullable=True),
sa.Column('vote_average', sa.Float(), nullable=True),
sa.Column('vote_count', sa.Integer(), nullable=True),
sa.Column('revenue', sa.Integer(), nullable=True),
sa.Column('runtime', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('tagline', sa.String(length=255), nullable=True),
sa.Column('last_viewed_at', sa.DateTime(), nullable=True),
sa.Column('view_count', sa.Integer(), nullable=False),
sa.Column('never_watched', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('added_at', sa.DateTime(), nullable=True),
sa.Column('removed_at', sa.DateTime(), nullable=True),
sa.Column('last_metadata_refresh_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tmdb_id'),
sa.UniqueConstraint('radarr_id'),
sa.UniqueConstraint('imdb_id'),
sa.Index('ix_movies_tmdb_id', 'tmdb_id'),
sa.Index('ix_movies_radarr_id', 'radarr_id'),
sa.Index('ix_movies_imdb_id', 'imdb_id'),
)


def _series_table(year_nullable: bool) -> sa.Table:
return sa.Table(
'series', sa.MetaData(),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('title', sa.String(length=512), nullable=False),
sa.Column('tmdb_id', sa.Integer(), nullable=False),
sa.Column('year', sa.SmallInteger(), nullable=year_nullable),
sa.Column('size', sa.Integer(), nullable=True),
sa.Column('sonarr_id', sa.Integer(), nullable=True),
sa.Column('imdb_id', sa.String(length=20), nullable=True),
sa.Column('tvdb_id', sa.String(length=20), nullable=True),
sa.Column('tmdb_title', sa.String(length=512), nullable=True),
sa.Column('original_title', sa.String(length=512), nullable=True),
sa.Column('tmdb_first_air_date', sa.DateTime(), nullable=True),
sa.Column('tmdb_last_air_date', sa.DateTime(), nullable=True),
sa.Column('original_language', sa.String(length=10), nullable=True),
sa.Column('homepage', sa.String(length=500), nullable=True),
sa.Column('origin_country', sa.JSON(), nullable=True),
sa.Column('poster_url', sa.String(length=500), nullable=True),
sa.Column('backdrop_url', sa.String(length=500), nullable=True),
sa.Column('overview', sa.Text(), nullable=True),
sa.Column('genres', sa.JSON(), nullable=True),
sa.Column('popularity', sa.Float(), nullable=True),
sa.Column('vote_average', sa.Float(), nullable=True),
sa.Column('vote_count', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('tagline', sa.String(length=255), nullable=True),
sa.Column('season_count', sa.Integer(), nullable=True),
sa.Column('last_viewed_at', sa.DateTime(), nullable=True),
sa.Column('view_count', sa.Integer(), nullable=False),
sa.Column('never_watched', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('added_at', sa.DateTime(), nullable=True),
sa.Column('removed_at', sa.DateTime(), nullable=True),
sa.Column('last_metadata_refresh_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tmdb_id'),
sa.UniqueConstraint('sonarr_id'),
sa.UniqueConstraint('imdb_id'),
sa.UniqueConstraint('tvdb_id'),
sa.Index('ix_series_tmdb_id', 'tmdb_id'),
sa.Index('ix_series_sonarr_id', 'sonarr_id'),
sa.Index('ix_series_imdb_id', 'imdb_id'),
sa.Index('ix_series_tvdb_id', 'tvdb_id'),
)


def upgrade() -> None:
conn = op.get_bind()
# drop any orphaned Alembic temp tables left by a previously interrupted migration.
for tbl in ('_alembic_tmp_movies', '_alembic_tmp_series'):
conn.execute(sa.text(f'DROP TABLE IF EXISTS "{tbl}"'))

# SQLite batch_alter_table recreates the table (copy → drop original → rename).
# The DROP TABLE fails if FK enforcement is on because child tables reference it.
conn.execute(sa.text('PRAGMA foreign_keys=OFF'))
try:
with op.batch_alter_table('movies', schema=None) as batch_op:
batch_op.alter_column('year',
existing_type=sa.SMALLINT(),
nullable=True)

with op.batch_alter_table('series', schema=None) as batch_op:
batch_op.alter_column('year',
existing_type=sa.SMALLINT(),
nullable=True)
with op.batch_alter_table('movies', recreate='always',
copy_from=_movies_table(year_nullable=False)) as batch_op:
batch_op.alter_column('year', existing_type=sa.SMALLINT(), nullable=True)

with op.batch_alter_table('series', recreate='always',
copy_from=_series_table(year_nullable=False)) as batch_op:
batch_op.alter_column('year', existing_type=sa.SMALLINT(), nullable=True)
finally:
conn.execute(sa.text('PRAGMA foreign_keys=ON'))

Expand All @@ -45,14 +127,12 @@ def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text('PRAGMA foreign_keys=OFF'))
try:
with op.batch_alter_table('series', schema=None) as batch_op:
batch_op.alter_column('year',
existing_type=sa.SMALLINT(),
nullable=False)

with op.batch_alter_table('movies', schema=None) as batch_op:
batch_op.alter_column('year',
existing_type=sa.SMALLINT(),
nullable=False)
with op.batch_alter_table('series', recreate='always',
copy_from=_series_table(year_nullable=True)) as batch_op:
batch_op.alter_column('year', existing_type=sa.SMALLINT(), nullable=False)

with op.batch_alter_table('movies', recreate='always',
copy_from=_movies_table(year_nullable=True)) as batch_op:
batch_op.alter_column('year', existing_type=sa.SMALLINT(), nullable=False)
finally:
conn.execute(sa.text('PRAGMA foreign_keys=ON'))
conn.execute(sa.text('PRAGMA foreign_keys=ON'))
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,27 @@
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '20666951d75d'
down_revision: Union[str, None] = '1b25d7fd62d3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('seasons', schema=None) as batch_op:
batch_op.add_column(sa.Column('emby_season_id', sa.String(length=100), nullable=True))

# ### end Alembic commands ###
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_cols = [c['name'] for c in inspector.get_columns('seasons')]
if 'emby_season_id' not in existing_cols:
with op.batch_alter_table('seasons', schema=None) as batch_op:
batch_op.add_column(
sa.Column('emby_season_id', sa.String(length=100), nullable=True)
)


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('seasons', schema=None) as batch_op:
batch_op.drop_column('emby_season_id')

# ### end Alembic commands ###
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_cols = [c['name'] for c in inspector.get_columns('seasons')]
if 'emby_season_id' in existing_cols:
with op.batch_alter_table('seasons', schema=None) as batch_op:
batch_op.drop_column('emby_season_id')
Loading
Loading